From be523e09e98d806da92689238475523b6df3a164 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 9 Aug 2026 01:52:47 +0200 Subject: [PATCH] feat(plugins): add Linear attachment source --- CLAUDE.md | 2 +- docs/plugins.md | 54 +++++- extensions/linear/README.md | 32 +++ extensions/linear/index.tsx | 45 +++++ extensions/linear/linear.test.ts | 135 +++++++++++++ extensions/linear/linear.ts | 183 ++++++++++++++++++ extensions/linear/paseo-plugin.json | 3 + .../attachment-pill-content.test.tsx | 29 +++ .../attachments/attachment-pill-content.tsx | 8 + packages/app/src/attachments/types.ts | 2 + packages/app/src/composer/actions.test.ts | 29 +++ .../app/src/composer/attachments/submit.ts | 6 + packages/app/src/composer/index.tsx | 35 +++- .../app/src/plugins/attachments/model.test.ts | 69 +++++++ packages/app/src/plugins/attachments/model.ts | 70 +++++++ .../app/src/plugins/attachments/picker.tsx | 176 +++++++++++++++++ packages/app/src/plugins/attachments/pill.tsx | 65 +++++++ .../app/src/plugins/attachments/schema.ts | 18 ++ packages/app/src/plugins/evaluate.test.ts | 47 +++++ packages/app/src/plugins/evaluate.ts | 59 +++++- packages/app/src/plugins/sdk.test.ts | 74 ++++++- packages/app/src/plugins/sdk.tsx | 28 +++ .../app/src/plugins/sidebar-groups.test.ts | 1 + packages/app/src/plugins/types.ts | 11 ++ .../app/src/stores/draft-store/state.test.ts | 28 +++ packages/app/src/stores/draft-store/state.ts | 4 + .../protocol/src/messages.attachments.test.ts | 44 +++++ packages/protocol/src/messages.ts | 11 ++ .../server/src/server/plugins/compiler.ts | 2 +- .../src/server/plugins/plugin-process.ts | 6 +- .../src/server/plugins/runtime.posix.test.ts | 30 ++- 31 files changed, 1291 insertions(+), 15 deletions(-) create mode 100644 extensions/linear/README.md create mode 100644 extensions/linear/index.tsx create mode 100644 extensions/linear/linear.test.ts create mode 100644 extensions/linear/linear.ts create mode 100644 extensions/linear/paseo-plugin.json create mode 100644 packages/app/src/attachments/attachment-pill-content.test.tsx create mode 100644 packages/app/src/plugins/attachments/model.test.ts create mode 100644 packages/app/src/plugins/attachments/model.ts create mode 100644 packages/app/src/plugins/attachments/picker.tsx create mode 100644 packages/app/src/plugins/attachments/pill.tsx create mode 100644 packages/app/src/plugins/attachments/schema.ts diff --git a/CLAUDE.md b/CLAUDE.md index 26a06231ae..36a844bf2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/docs/plugins.md b/docs/plugins.md index 2e896f3251..c8862478de 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. diff --git a/extensions/linear/README.md b/extensions/linear/README.md new file mode 100644 index 0000000000..cf7d6afa89 --- /dev/null +++ b/extensions/linear/README.md @@ -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. diff --git a/extensions/linear/index.tsx b/extensions/linear/index.tsx new file mode 100644 index 0000000000..a56eddfb8b --- /dev/null +++ b/extensions/linear/index.tsx @@ -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 => { + const linear = createLinearIssueSearch({ apiKey: process.env.LINEAR_API_KEY ?? "" }); + return linear.search(query); + }); + plugin.addAttachmentSource(issueAttachments); +} diff --git a/extensions/linear/linear.test.ts b/extensions/linear/linear.test.ts new file mode 100644 index 0000000000..01fa002697 --- /dev/null +++ b/extensions/linear/linear.test.ts @@ -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 }; +} + +async function readRequest(request: IncomingMessage): Promise { + 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( + respond: (request: CapturedRequest, response: ServerResponse) => void, + run: (endpoint: string) => Promise, +): Promise { + const server = createServer((request, response) => { + void readRequest(request).then((captured) => respond(captured, response)); + }); + await new Promise((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((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", + ); + }); +}); diff --git a/extensions/linear/linear.ts b/extensions/linear/linear.ts new file mode 100644 index 0000000000..ede9e1c5f5 --- /dev/null +++ b/extensions/linear/linear.ts @@ -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; +} + +class LinearApiError extends Error { + constructor(message: string) { + super(message); + this.name = "LinearApiError"; + } +} + +function issueSubtitle(issue: z.infer): 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): 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): 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 { + 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 { + 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 { + 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 }; + }, + }; +} diff --git a/extensions/linear/paseo-plugin.json b/extensions/linear/paseo-plugin.json new file mode 100644 index 0000000000..c8fa978d91 --- /dev/null +++ b/extensions/linear/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "linear" +} diff --git a/packages/app/src/attachments/attachment-pill-content.test.tsx b/packages/app/src/attachments/attachment-pill-content.test.tsx new file mode 100644 index 0000000000..bfd33a3294 --- /dev/null +++ b/packages/app/src/attachments/attachment-pill-content.test.tsx @@ -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"); + }); +}); diff --git a/packages/app/src/attachments/attachment-pill-content.tsx b/packages/app/src/attachments/attachment-pill-content.tsx index 8acc585fb5..ed37c0d787 100644 --- a/packages/app/src/attachments/attachment-pill-content.tsx +++ b/packages/app/src/attachments/attachment-pill-content.tsx @@ -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"), diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 7e53567b23..07a1421acf 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -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 diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 743f5ebffe..4e66d8ef21 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -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", () => { diff --git a/packages/app/src/composer/attachments/submit.ts b/packages/app/src/composer/attachments/submit.ts index c1c15bb80c..1d314ac002 100644 --- a/packages/app/src/composer/attachments/submit.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -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); diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 02e8d6d1be..f285e41438 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ( + + ); + } return ( (null); const attachButtonRef = useRef(null); const messageInputRef = useRef(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} diff --git a/packages/app/src/plugins/attachments/model.test.ts b/packages/app/src/plugins/attachments/model.test.ts new file mode 100644 index 0000000000..3aeb112eaf --- /dev/null +++ b/packages/app/src/plugins/attachments/model.test.ts @@ -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], + }); + }); +}); diff --git a/packages/app/src/plugins/attachments/model.ts b/packages/app/src/plugins/attachments/model.ts new file mode 100644 index 0000000000..0e1fe36fa2 --- /dev/null +++ b/packages/app/src/plugins/attachments/model.ts @@ -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, + }, + }; +} diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx new file mode 100644 index 0000000000..e4e594cb81 --- /dev/null +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -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 ; +} + +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; +} + +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(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: , + onSelect: () => setActiveKey(key), + }; + }), + [sources], + ); + if (!active) return { menuItems, picker: null }; + return { + menuItems, + picker: ( + + ), + }; +} diff --git a/packages/app/src/plugins/attachments/pill.tsx b/packages/app/src/plugins/attachments/pill.tsx new file mode 100644 index 0000000000..18fb926b59 --- /dev/null +++ b/packages/app/src/plugins/attachments/pill.tsx @@ -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; + color?: string; +}) { + return ; +} + +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( + () => , + [Icon], + ); + return ( + + + + ); +} diff --git a/packages/app/src/plugins/attachments/schema.ts b/packages/app/src/plugins/attachments/schema.ts new file mode 100644 index 0000000000..3ae40e43c8 --- /dev/null +++ b/packages/app/src/plugins/attachments/schema.ts @@ -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; +export type PluginAttachmentSearchPayload = z.infer; diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 3f32de51dd..de9451b017 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index b20c0f4598..f9f7647078 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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(); const sidebarItemIds = new Set(); + const attachmentSourceIds = new Set(); const pluginContext = createPluginContext({ addSurface(surfaceId: string, Component: ComponentType) { 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, + }; } diff --git a/packages/app/src/plugins/sdk.test.ts b/packages/app/src/plugins/sdk.test.ts index e4a35ef391..7fa3936e33 100644 --- a/packages/app/src/plugins/sdk.test.ts +++ b/packages/app/src/plugins/sdk.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/plugins/sdk.tsx b/packages/app/src/plugins/sdk.tsx index 58230d6949..688064cdfe 100644 --- a/packages/app/src/plugins/sdk.tsx +++ b/packages/app/src/plugins/sdk.tsx @@ -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): void; addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; } interface PluginRpcContextValue { @@ -40,6 +46,7 @@ export interface PluginContext { ): void; addSurface(id: string, Component: ComponentType): void; addSidebarItem(contribution: PluginSidebarContribution): void; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): void; } interface RpcDefinition { @@ -58,6 +65,14 @@ export function defineRpc(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, + query: string, +): Promise { + const output = await callPluginRpc(source.search, invoke, { query }); + return PluginAttachmentSearchPayloadSchema.parseAsync(output); +} + export function useRpc( contract: PluginRpcContract, ): (input: ZodInput) => Promise> { @@ -107,4 +134,5 @@ export async function callPluginRpc; @@ -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 { diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index 56807d085a..a71ea96668 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -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, diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index a2314ec3a1..4fc7bca3e7 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -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" && diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 2a037d9139..7d544e3e65 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -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", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index e575e3819b..e53afcc7de 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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, diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 955b876443..1726f10bf7 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -7,7 +7,7 @@ type PluginBuildTarget = "client" | "server"; const REGISTRATIONS_REMOVED_BY_TARGET: Record> = { client: new Set(["handle"]), - server: new Set(["addSurface", "addSidebarItem"]), + server: new Set(["addSurface", "addSidebarItem", "addAttachmentSource"]), }; function registrationName(statement: unknown, contextName: string): string | null { diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 3031c92b01..0e311c3213 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -41,6 +41,10 @@ function defineRpc(contract: RpcContract): RpcContract { return { ...contract, name: contract.name.trim() }; } +function defineAttachmentSource(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); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 2293a97cb6..e4d519a154 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -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 Hello from native UI; } @@ -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({