diff --git a/packages/db/src/migrations/0107_external_object_display_metadata.sql b/packages/db/src/migrations/0107_external_object_display_metadata.sql new file mode 100644 index 00000000000..3888f9ea1ff --- /dev/null +++ b/packages/db/src/migrations/0107_external_object_display_metadata.sql @@ -0,0 +1,5 @@ +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "display_key" text; +--> statement-breakpoint +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "icon_key" text; +--> statement-breakpoint +ALTER TABLE "external_objects" ADD COLUMN IF NOT EXISTS "status_icon_key" text; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4e818013a0c..06099862a1a 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -750,6 +750,13 @@ "when": 1781902100000, "tag": "0106_external_object_references", "breakpoints": true + }, + { + "idx": 107, + "version": "7", + "when": 1782165200000, + "tag": "0107_external_object_display_metadata", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/external_objects.ts b/packages/db/src/schema/external_objects.ts index d3028adef9e..867702987dd 100644 --- a/packages/db/src/schema/external_objects.ts +++ b/packages/db/src/schema/external_objects.ts @@ -27,9 +27,12 @@ export const externalObjects = pgTable( externalId: text("external_id").notNull(), sanitizedCanonicalUrl: text("sanitized_canonical_url"), canonicalIdentityHash: text("canonical_identity_hash"), + displayKey: text("display_key"), + iconKey: text("icon_key"), displayTitle: text("display_title"), statusKey: text("status_key"), statusLabel: text("status_label"), + statusIconKey: text("status_icon_key"), statusCategory: text("status_category").$type().notNull().default("unknown"), statusTone: text("status_tone").$type().notNull().default("neutral"), liveness: text("liveness").$type().notNull().default("unknown"), diff --git a/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts b/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts index 7058d72060f..2c639757e9f 100644 --- a/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts +++ b/packages/plugins/examples/plugin-mock-object-provider-example/src/worker.ts @@ -18,6 +18,8 @@ const plugin = definePlugin({ providerKey: "mocktracker", objectType: "ticket", externalId: `MOCK-${id}`, + displayKey: "Mock Ticket", + iconKey: "circle-dot", displayTitle: `Mock ticket ${id}`, confidence: "exact" as const, }]; @@ -30,8 +32,11 @@ const plugin = definePlugin({ ok: true as const, snapshot: { displayTitle: `Resolved ${params.externalId}`, + displayKey: "Mock Ticket", + iconKey: "circle-dot", statusKey: "ready", statusLabel: "Ready", + statusIconKey: "check-circle", statusCategory: "succeeded" as const, statusTone: "success" as const, isTerminal: false, diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index f70b4bcf31f..3a8b866d144 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -398,9 +398,10 @@ capabilities: ["external.objects.detect", "external.objects.read"], ``` Implement `onDetectExternalObjects()` to map sanitized URL candidates to -`providerKey`, `objectType`, and provider-stable `externalId`. Implement -`onResolveExternalObject()` to return a normalized snapshot with -`statusCategory`, `statusTone`, `statusLabel`, board-safe `data`, and freshness +`providerKey`, `objectType`, provider-stable `externalId`, and optional display +metadata such as `displayKey`/`iconKey`. Implement `onResolveExternalObject()` +to return a normalized snapshot with `statusCategory`, `statusTone`, +`statusLabel`, optional `statusIconKey`, board-safe `data`, and freshness metadata. Slow or failing plugins are isolated: Paperclip logs the failure and continues saving the source issue, comment, or document. diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 52560404de9..c56a0dd23e8 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -465,6 +465,8 @@ export interface PluginExternalObjectDetection { providerKey: string; objectType: string; externalId: string; + displayKey?: string | null; + iconKey?: string | null; displayTitle?: string | null; confidence?: ExternalObjectMentionConfidence; } @@ -481,9 +483,12 @@ export interface PluginExternalObjectRecordSnapshot { externalId: string; sanitizedCanonicalUrl: string | null; canonicalIdentityHash: string | null; + displayKey: string | null; + iconKey: string | null; displayTitle: string | null; statusKey: string | null; statusLabel: string | null; + statusIconKey: string | null; statusCategory: ExternalObjectStatusCategory; statusTone: ExternalObjectStatusTone; liveness: ExternalObjectLivenessState; @@ -502,9 +507,12 @@ export interface ResolveExternalObjectParams { } export interface PluginExternalObjectResolvedSnapshot { + displayKey?: string | null; + iconKey?: string | null; displayTitle?: string | null; statusKey?: string | null; statusLabel?: string | null; + statusIconKey?: string | null; statusCategory: ExternalObjectStatusCategory; statusTone: ExternalObjectStatusTone; isTerminal?: boolean; diff --git a/packages/shared/src/types/external-object.ts b/packages/shared/src/types/external-object.ts index a6d43c2d075..feca7de9cda 100644 --- a/packages/shared/src/types/external-object.ts +++ b/packages/shared/src/types/external-object.ts @@ -15,9 +15,12 @@ export interface ExternalObject { externalId: string; sanitizedCanonicalUrl: string | null; canonicalIdentityHash: string | null; + displayKey?: string | null; + iconKey?: string | null; displayTitle: string | null; statusKey: string | null; statusLabel: string | null; + statusIconKey?: string | null; statusCategory: ExternalObjectStatusCategory; statusTone: ExternalObjectStatusTone; liveness: ExternalObjectLivenessState; @@ -68,7 +71,10 @@ export interface ExternalObjectSummaryItem { id: string; providerKey: string; objectType: string; + displayKey?: string | null; + iconKey?: string | null; displayTitle: string | null; + statusIconKey?: string | null; statusCategory: ExternalObjectStatusCategory; statusTone: ExternalObjectStatusTone; liveness: ExternalObjectLivenessState; diff --git a/server/src/__tests__/external-objects-service.test.ts b/server/src/__tests__/external-objects-service.test.ts index 8d589352bfe..0b2eaee0969 100644 --- a/server/src/__tests__/external-objects-service.test.ts +++ b/server/src/__tests__/external-objects-service.test.ts @@ -182,12 +182,16 @@ describe("GitHub external object provider", () => { providerKey: "github", objectType: "pull_request", externalId: "acme/app#pull/42", + displayKey: "GitHub PR", + iconKey: "github", displayTitle: "Acme/App#42", }), expect.objectContaining({ providerKey: "github", objectType: "issue", externalId: "acme/app#issues/7", + displayKey: "GitHub Issue", + iconKey: "github", displayTitle: "Acme/App#7", }), ]); @@ -198,22 +202,22 @@ describe("GitHub external object provider", () => { [ "open", { state: "open", draft: false, merged: false, title: "Ship it", updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "open", statusLabel: "Open", statusCategory: "open", statusTone: "info", isTerminal: false }, + { statusKey: "open", statusLabel: "Open", statusIconKey: "git-pull-request", statusCategory: "open", statusTone: "info", isTerminal: false }, ], [ "draft", { state: "open", draft: true, merged: false, title: "WIP", updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "draft", statusLabel: "Draft", statusCategory: "waiting", statusTone: "warning", isTerminal: false }, + { statusKey: "draft", statusLabel: "Draft", statusIconKey: "clock", statusCategory: "waiting", statusTone: "warning", isTerminal: false }, ], [ "closed", { state: "closed", draft: false, merged: false, title: "Closed PR", updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "closed", statusLabel: "Closed", statusCategory: "closed", statusTone: "muted", isTerminal: true }, + { statusKey: "closed", statusLabel: "Closed", statusIconKey: "x-circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, ], [ "merged", { state: "closed", draft: false, merged: true, title: "Merged PR", updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "merged", statusLabel: "Merged", statusCategory: "succeeded", statusTone: "success", isTerminal: true }, + { statusKey: "merged", statusLabel: "Merged", statusIconKey: "git-merge", statusCategory: "succeeded", statusTone: "success", isTerminal: true }, ], ])("resolves a %s pull request snapshot", async (_name, body, expected) => { const fetch = vi.fn(async () => response(body)); @@ -235,6 +239,8 @@ describe("GitHub external object provider", () => { ok: true, snapshot: expect.objectContaining({ ...expected, + displayKey: "GitHub PR", + iconKey: "github", displayTitle: expect.stringContaining(String(body.title)), remoteVersion: "2026-04-24T01:02:03Z", etag: '"etag-1"', @@ -253,12 +259,12 @@ describe("GitHub external object provider", () => { [ "open", { state: "open", title: "Issue", state_reason: null, updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "open", statusLabel: "Open", statusCategory: "open", statusTone: "info", isTerminal: false }, + { statusKey: "open", statusLabel: "Open", statusIconKey: "circle-dot", statusCategory: "open", statusTone: "info", isTerminal: false }, ], [ "closed", { state: "closed", title: "Issue", state_reason: "completed", updated_at: "2026-04-24T01:02:03Z" }, - { statusKey: "closed_completed", statusLabel: "Closed: completed", statusCategory: "closed", statusTone: "muted", isTerminal: true }, + { statusKey: "closed_completed", statusLabel: "Closed: completed", statusIconKey: "circle", statusCategory: "closed", statusTone: "muted", isTerminal: true }, ], ])("resolves a %s issue snapshot", async (_name, body, expected) => { const fetch = vi.fn(async () => response(body)); @@ -275,6 +281,8 @@ describe("GitHub external object provider", () => { ok: true, snapshot: expect.objectContaining({ ...expected, + displayKey: "GitHub Issue", + iconKey: "github", data: expect.objectContaining({ provider: "github", owner: "acme", @@ -322,7 +330,7 @@ describe("GitHub external object provider", () => { [ "not-found", new Response("", { status: 404, headers: { etag: '"missing"' } }), - { ok: true, snapshot: expect.objectContaining({ statusKey: "not_found", statusCategory: "archived", statusTone: "muted" }) }, + { ok: true, snapshot: expect.objectContaining({ displayKey: "GitHub PR", iconKey: "github", statusKey: "not_found", statusIconKey: "archive", statusCategory: "archived", statusTone: "muted" }) }, ], ])("maps %s responses to provider-safe results", async (_name, githubResponse, expected) => { const provider = createGitHubExternalObjectProvider({} as any, { @@ -548,6 +556,8 @@ describeEmbeddedPostgres("externalObjectService", () => { providerKey: "mocktracker", objectType: "ticket", externalId: "MOCK-123", + displayKey: "Mock Ticket", + iconKey: "circle-dot", displayTitle: "Mock ticket 123", confidence: "exact", })), @@ -557,9 +567,12 @@ describeEmbeddedPostgres("externalObjectService", () => { return { ok: true, snapshot: { + displayKey: "Mock Ticket", + iconKey: "circle-dot", displayTitle: `Resolved ${params.externalId}`, statusKey: "ready", statusLabel: "Ready", + statusIconKey: "check-circle", statusCategory: "succeeded", statusTone: "success", ttlSeconds: 300, @@ -579,6 +592,8 @@ describeEmbeddedPostgres("externalObjectService", () => { providerKey: "mocktracker", objectType: "ticket", externalId: "MOCK-123", + displayKey: "Mock Ticket", + iconKey: "circle-dot", pluginId: plugin!.id, sanitizedCanonicalUrl: "https://mock.example/tickets/123", }); @@ -586,6 +601,10 @@ describeEmbeddedPostgres("externalObjectService", () => { const refreshed = await svc.refreshObject(object.id, { companyId, force: true }); expect(refreshed.object).toMatchObject({ + displayKey: "Mock Ticket", + iconKey: "circle-dot", + displayTitle: "Resolved MOCK-123", + statusIconKey: "check-circle", statusCategory: "succeeded", statusTone: "success", liveness: "fresh", diff --git a/server/src/services/external-objects.ts b/server/src/services/external-objects.ts index f9ac6f3931a..ba0a292b49c 100644 --- a/server/src/services/external-objects.ts +++ b/server/src/services/external-objects.ts @@ -35,6 +35,8 @@ export interface ExternalObjectDetection { providerKey: string; objectType: string; externalId: string; + displayKey?: string | null; + iconKey?: string | null; displayTitle?: string | null; confidence?: ExternalObjectMentionConfidence; pluginId?: string | null; @@ -50,9 +52,12 @@ export interface ExternalObjectDetector { } export interface ExternalObjectResolverSnapshot { + displayKey?: string | null; + iconKey?: string | null; displayTitle?: string | null; statusKey?: string | null; statusLabel?: string | null; + statusIconKey?: string | null; statusCategory: ExternalObjectStatusCategory; statusTone: ExternalObjectStatusTone; isTerminal?: boolean; @@ -126,6 +131,7 @@ function objectChanged(before: ExternalObjectRecord, after: ExternalObjectRecord return ( before.statusKey !== after.statusKey || before.statusLabel !== after.statusLabel || + before.statusIconKey !== after.statusIconKey || before.statusCategory !== after.statusCategory || before.statusTone !== after.statusTone || before.isTerminal !== after.isTerminal @@ -217,9 +223,12 @@ function objectSnapshot(object: ExternalObjectRecord): PluginExternalObjectRecor externalId: object.externalId, sanitizedCanonicalUrl: object.sanitizedCanonicalUrl, canonicalIdentityHash: object.canonicalIdentityHash, + displayKey: object.displayKey, + iconKey: object.iconKey, displayTitle: object.displayTitle, statusKey: object.statusKey, statusLabel: object.statusLabel, + statusIconKey: object.statusIconKey, statusCategory: object.statusCategory, statusTone: object.statusTone, liveness: object.liveness, @@ -285,6 +294,8 @@ function createPluginProviderDetector( providerKey: detection.providerKey, objectType: detection.objectType, externalId: detection.externalId, + displayKey: detection.displayKey, + iconKey: detection.iconKey, displayTitle: detection.displayTitle, confidence: detection.confidence ?? "exact", pluginId: provider.id, @@ -399,6 +410,8 @@ export function externalObjectService( externalId: detection.externalId, sanitizedCanonicalUrl: canonical.sanitizedCanonicalUrl, canonicalIdentityHash: canonical.canonicalIdentityHash, + displayKey: detection.displayKey ?? null, + iconKey: detection.iconKey ?? null, displayTitle: detection.displayTitle ?? canonical.sanitizedDisplayUrl, updatedAt: now, }; @@ -415,6 +428,8 @@ export function externalObjectService( set: { sanitizedCanonicalUrl: values.sanitizedCanonicalUrl, canonicalIdentityHash: values.canonicalIdentityHash, + displayKey: values.displayKey, + iconKey: values.iconKey, displayTitle: values.displayTitle, updatedAt: now, }, @@ -663,7 +678,10 @@ export function externalObjectService( id: object.id, providerKey: object.providerKey, objectType: object.objectType, + displayKey: object.displayKey, + iconKey: object.iconKey, displayTitle: object.displayTitle, + statusIconKey: object.statusIconKey, statusCategory: object.statusCategory, statusTone: object.statusTone, liveness: object.liveness, @@ -796,9 +814,12 @@ export function externalObjectService( const snapshot = result.snapshot; const patch = { + displayKey: snapshot.displayKey ?? object.displayKey, + iconKey: snapshot.iconKey ?? object.iconKey, displayTitle: snapshot.displayTitle ?? object.displayTitle, statusKey: snapshot.statusKey ?? object.statusKey, statusLabel: snapshot.statusLabel ?? object.statusLabel, + statusIconKey: snapshot.statusIconKey ?? object.statusIconKey, statusCategory: snapshot.statusCategory, statusTone: snapshot.statusTone, isTerminal: snapshot.isTerminal ?? object.isTerminal, diff --git a/server/src/services/github-external-object-provider.ts b/server/src/services/github-external-object-provider.ts index 7d19e58c1f4..6d8a7c91714 100644 --- a/server/src/services/github-external-object-provider.ts +++ b/server/src/services/github-external-object-provider.ts @@ -105,6 +105,10 @@ function displayTitleFor(identity: GitHubObjectIdentity) { return `${identity.owner}/${identity.repo}#${identity.number}`; } +function displayKeyFor(identity: Pick) { + return identity.objectType === "pull_request" ? "GitHub PR" : "GitHub Issue"; +} + function retryAfterSeconds(response: Response) { const retryAfter = response.headers.get("retry-after"); if (retryAfter && /^[0-9]+$/.test(retryAfter)) return Number(retryAfter); @@ -163,9 +167,12 @@ function failureFromGitHubResponse(response: Response): ExternalObjectResolveRes function notFoundSnapshot(identity: GitHubObjectIdentity, etag: string | null): ExternalObjectResolverSnapshot { return { + displayKey: displayKeyFor(identity), + iconKey: "github", displayTitle: displayTitleFor(identity), statusKey: "not_found", statusLabel: "Not found", + statusIconKey: "archive", statusCategory: "archived", statusTone: "muted", isTerminal: true, @@ -217,9 +224,18 @@ function pullRequestSnapshot(identity: GitHubObjectIdentity, body: Record { ); expect(html).not.toContain("×"); }); + + it("uses provider display keys, icons, visible status, and a truncated title when supplied", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("GitHub PR"); + expect(html).toContain("Merged"); + expect(html).toContain("acme/web#241: Add rich object presentation metadata"); + expect(html).toContain('aria-label="GitHub pull request — Merged: acme/web#241: Add rich object presentation metadata"'); + }); }); describe("ExternalObjectStatusSummary", () => { diff --git a/ui/src/components/ExternalObjectPill.tsx b/ui/src/components/ExternalObjectPill.tsx index bdd0b6e6c37..d473806c476 100644 --- a/ui/src/components/ExternalObjectPill.tsx +++ b/ui/src/components/ExternalObjectPill.tsx @@ -12,6 +12,7 @@ import { import { externalObjectCategoryLabel, externalObjectLivenessLabel, + externalObjectIconForKey, externalObjectProviderLabel, externalObjectTypeLabel, } from "../lib/external-objects"; @@ -20,10 +21,13 @@ import { cn } from "../lib/utils"; export interface ExternalObjectPillData { providerKey: string | null; objectType: string | null; + displayKey?: string | null; + iconKey?: string | null; statusCategory: ExternalObjectStatusCategory; liveness: ExternalObjectLivenessState; displayTitle?: string | null; statusLabel?: string | null; + statusIconKey?: string | null; url?: string | null; } @@ -59,8 +63,10 @@ export function ExternalObjectPill({ const overlay = externalObjectLivenessOverlay[object.liveness] ?? ""; const providerLabel = externalObjectProviderLabel(object.providerKey); const typeLabel = externalObjectTypeLabel(object.objectType); + const displayKey = object.displayKey?.trim() || `${providerLabel} ${typeLabel}`; const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory); const livenessLabel = externalObjectLivenessLabel(object.liveness); + const ProviderIcon = externalObjectIconForKey(object.iconKey); const ariaLabel = `${providerLabel} ${typeLabel} — ${statusLabel}${ object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})` }${object.displayTitle ? `: ${object.displayTitle}` : ""}`; @@ -68,7 +74,7 @@ export function ExternalObjectPill({ const interactive = !inert && Boolean(object.url); const classNames = cn( "paperclip-mention-chip paperclip-mention-chip--external-object", - "inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", + "inline-flex max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline", // Tone is applied as text classes only — the border style comes from the // overlay (dashed for stale/auth/unreachable). tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), @@ -78,12 +84,24 @@ export function ExternalObjectPill({ className, ); const titleAttr = sourceSummary - ? `${object.displayTitle ?? `${providerLabel} ${typeLabel}`} — ${sourceSummary}` - : object.displayTitle ?? `${providerLabel} ${typeLabel}`; + ? `${object.displayTitle ?? displayKey} — ${sourceSummary}` + : object.displayTitle ?? displayKey; const labelText = children ?? ( <> - {providerLabel.toLowerCase()} - {typeLabel} + {displayKey} + + + {statusLabel} + + {object.displayTitle ? ( + {object.displayTitle} + ) : null} ); const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? ( @@ -91,13 +109,18 @@ export function ExternalObjectPill({ ) : null; const innerContent = ( <> - - + {ProviderIcon ? ( +