mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Allow external objects to define presentation metadata
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
85351a2c9a
commit
95acb60120
19 files changed
+228
-30
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<ExternalObjectStatusCategory>().notNull().default("unknown"),
|
||||
statusTone: text("status_tone").$type<ExternalObjectStatusTone>().notNull().default("neutral"),
|
||||
liveness: text("liveness").$type<ExternalObjectLivenessState>().notNull().default("unknown"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -105,6 +105,10 @@ function displayTitleFor(identity: GitHubObjectIdentity) {
|
||||
return `${identity.owner}/${identity.repo}#${identity.number}`;
|
||||
}
|
||||
|
||||
function displayKeyFor(identity: Pick<GitHubObjectIdentity, "objectType">) {
|
||||
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<string
|
||||
}
|
||||
|
||||
return {
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
|
||||
statusKey,
|
||||
statusLabel,
|
||||
statusIconKey: merged
|
||||
? "git-merge"
|
||||
: state === "closed"
|
||||
? "x-circle"
|
||||
: draft
|
||||
? "clock"
|
||||
: "git-pull-request",
|
||||
statusCategory,
|
||||
statusTone,
|
||||
isTerminal,
|
||||
@@ -257,9 +273,12 @@ function issueSnapshot(identity: GitHubObjectIdentity, body: Record<string, unkn
|
||||
: "Unknown";
|
||||
|
||||
return {
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: title ? `${displayTitleFor(identity)}: ${title}` : displayTitleFor(identity),
|
||||
statusKey,
|
||||
statusLabel,
|
||||
statusIconKey: state === "closed" ? "circle" : "circle-dot",
|
||||
statusCategory: state === "open" ? "open" : state === "closed" ? "closed" : "unknown",
|
||||
statusTone: state === "open" ? "info" : state === "closed" ? "muted" : "neutral",
|
||||
isTerminal: state === "closed",
|
||||
@@ -320,6 +339,8 @@ export function createGitHubExternalObjectProvider(
|
||||
providerKey: "github",
|
||||
objectType: identity.objectType,
|
||||
externalId: externalIdFor(identity),
|
||||
displayKey: displayKeyFor(identity),
|
||||
iconKey: "github",
|
||||
displayTitle: displayTitleFor(identity),
|
||||
confidence: "exact",
|
||||
}];
|
||||
|
||||
@@ -90,6 +90,29 @@ describe("ExternalObjectPill", () => {
|
||||
);
|
||||
expect(html).not.toContain("×");
|
||||
});
|
||||
|
||||
it("uses provider display keys, icons, visible status, and a truncated title when supplied", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ExternalObjectPill
|
||||
object={{
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayKey: "GitHub PR",
|
||||
iconKey: "github",
|
||||
statusCategory: "succeeded",
|
||||
statusIconKey: "git-merge",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "acme/web#241: Add rich object presentation metadata",
|
||||
url: "https://github.com/acme/web/pull/241",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -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 ?? (
|
||||
<>
|
||||
<span className="font-medium">{providerLabel.toLowerCase()}</span>
|
||||
<span className="text-muted-foreground/80">{typeLabel}</span>
|
||||
<span className="font-medium">{displayKey}</span>
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-background/70 px-1 text-[10px] font-medium">
|
||||
<ExternalObjectStatusIcon
|
||||
category={object.statusCategory}
|
||||
liveness={object.liveness}
|
||||
statusIconKey={object.statusIconKey}
|
||||
sizeClassName="h-2.5 w-2.5"
|
||||
label={`${providerLabel}: ${statusLabel}`}
|
||||
/>
|
||||
<span>{statusLabel}</span>
|
||||
</span>
|
||||
{object.displayTitle ? (
|
||||
<span className="max-w-[16rem] truncate text-muted-foreground/80">{object.displayTitle}</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
|
||||
@@ -91,13 +109,18 @@ export function ExternalObjectPill({
|
||||
) : null;
|
||||
const innerContent = (
|
||||
<>
|
||||
<ExternalObjectStatusIcon
|
||||
category={object.statusCategory}
|
||||
liveness={object.liveness}
|
||||
sizeClassName="h-3 w-3"
|
||||
label={`${providerLabel}: ${statusLabel}`}
|
||||
/>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon aria-hidden="true" className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ExternalObjectStatusIcon
|
||||
category={object.statusCategory}
|
||||
liveness={object.liveness}
|
||||
statusIconKey={object.statusIconKey}
|
||||
sizeClassName="h-3 w-3"
|
||||
label={`${providerLabel}: ${statusLabel}`}
|
||||
/>
|
||||
)}
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
{labelText}
|
||||
</span>
|
||||
{countSuffix}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
externalObjectLivenessLabel,
|
||||
} from "../lib/external-objects";
|
||||
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
|
||||
@@ -18,6 +19,7 @@ import { cn } from "../lib/utils";
|
||||
interface ExternalObjectStatusIconProps {
|
||||
category: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
statusIconKey?: string | null;
|
||||
/** Optional override label used in `aria-label` (e.g. provider-specific copy). */
|
||||
label?: string | null;
|
||||
className?: string;
|
||||
@@ -43,13 +45,14 @@ interface ExternalObjectStatusIconProps {
|
||||
export function ExternalObjectStatusIcon({
|
||||
category,
|
||||
liveness,
|
||||
statusIconKey,
|
||||
label,
|
||||
className,
|
||||
sizeClassName = "h-3.5 w-3.5",
|
||||
inline = false,
|
||||
}: ExternalObjectStatusIconProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const Icon = externalObjectIconForCategory(category);
|
||||
const Icon = externalObjectIconForKey(statusIconKey) ?? externalObjectIconForCategory(category);
|
||||
const tone = externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault;
|
||||
const livenessSuffix = liveness === "fresh" || liveness === "unknown"
|
||||
? ""
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
externalObjectCategoryLabel,
|
||||
externalObjectDominantCount,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
} from "../lib/external-objects";
|
||||
import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors";
|
||||
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
|
||||
@@ -23,6 +24,10 @@ function dominantCategory(summary: ExternalObjectSummary): string {
|
||||
return match?.statusCategory ?? "unknown";
|
||||
}
|
||||
|
||||
function dominantObject(summary: ExternalObjectSummary) {
|
||||
return summary.objects.find((object) => object.statusTone === summary.highestSeverity) ?? null;
|
||||
}
|
||||
|
||||
function buildBreakdownTitle(summary: ExternalObjectSummary): string {
|
||||
const parts: string[] = [];
|
||||
for (const [category, count] of Object.entries(summary.byStatusCategory)) {
|
||||
@@ -49,8 +54,9 @@ export function ExternalObjectStatusSummary({
|
||||
const total = summary?.total ?? 0;
|
||||
if (!summary || total === 0 || !tone) return null;
|
||||
|
||||
const category = dominantCategory(summary);
|
||||
const Icon = externalObjectIconForCategory(category);
|
||||
const object = dominantObject(summary);
|
||||
const category = object?.statusCategory ?? dominantCategory(summary);
|
||||
const Icon = externalObjectIconForKey(object?.statusIconKey) ?? externalObjectIconForCategory(category);
|
||||
const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault;
|
||||
const dominantCount = externalObjectDominantCount(summary);
|
||||
const title = buildBreakdownTitle(summary);
|
||||
|
||||
@@ -35,9 +35,12 @@ import type {
|
||||
export interface MarkdownExternalReference {
|
||||
providerKey: string | null;
|
||||
objectType: string | null;
|
||||
displayKey?: string | null;
|
||||
iconKey?: string | null;
|
||||
statusCategory: ExternalObjectStatusCategory;
|
||||
liveness: ExternalObjectLivenessState;
|
||||
statusLabel?: string | null;
|
||||
statusIconKey?: string | null;
|
||||
displayTitle?: string | null;
|
||||
}
|
||||
|
||||
@@ -115,14 +118,15 @@ function MarkdownExternalLink({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const provider = externalObjectProviderLabel(reference.providerKey);
|
||||
const displayKey = reference.displayKey?.trim() || provider;
|
||||
const statusLabel = reference.statusLabel ?? externalObjectCategoryLabel(reference.statusCategory);
|
||||
const livenessLabel = externalObjectLivenessLabel(reference.liveness);
|
||||
const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown"
|
||||
? ""
|
||||
: ` (${livenessLabel})`;
|
||||
const titleParts = [
|
||||
reference.displayTitle ?? `${provider} ${statusLabel}`,
|
||||
`${provider} — ${statusLabel}${livenessSuffix}`,
|
||||
reference.displayTitle ?? `${displayKey} ${statusLabel}`,
|
||||
`${displayKey} — ${statusLabel}${livenessSuffix}`,
|
||||
];
|
||||
const title = titleParts.filter(Boolean).join(" · ");
|
||||
return (
|
||||
@@ -134,13 +138,14 @@ function MarkdownExternalLink({
|
||||
data-external-status={reference.statusCategory}
|
||||
data-external-liveness={reference.liveness}
|
||||
title={title}
|
||||
aria-label={`${provider} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
|
||||
aria-label={`${displayKey} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
|
||||
className="paperclip-markdown-external-ref"
|
||||
>
|
||||
<ExternalObjectStatusIcon
|
||||
category={reference.statusCategory}
|
||||
liveness={reference.liveness}
|
||||
label={`${provider}: ${statusLabel}`}
|
||||
statusIconKey={reference.statusIconKey}
|
||||
label={`${displayKey}: ${statusLabel}`}
|
||||
inline
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -37,7 +37,10 @@ describe("MarkdownBody external object decoration", () => {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github",
|
||||
objectType: "pull_request",
|
||||
displayKey: "GitHub PR",
|
||||
iconKey: "github",
|
||||
statusCategory: "succeeded",
|
||||
statusIconKey: "git-merge",
|
||||
liveness: "fresh",
|
||||
statusLabel: "Merged",
|
||||
displayTitle: "Add external refs",
|
||||
@@ -50,7 +53,7 @@ describe("MarkdownBody external object decoration", () => {
|
||||
expect(html).toContain('data-external-link="resolved"');
|
||||
expect(html).toContain('data-external-status="succeeded"');
|
||||
expect(html).toContain('data-external-liveness="fresh"');
|
||||
expect(html).toContain('aria-label="GitHub Merged: Add external refs"');
|
||||
expect(html).toContain('aria-label="GitHub PR Merged: Add external refs"');
|
||||
expect(html).toContain("github.com/acme/web/pull/241");
|
||||
});
|
||||
|
||||
|
||||
@@ -96,10 +96,13 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss
|
||||
pill: {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
displayTitle: object.displayTitle,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
url: object.sanitizedCanonicalUrl,
|
||||
},
|
||||
};
|
||||
@@ -117,9 +120,12 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss
|
||||
result[canonical] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
@@ -133,9 +139,12 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss
|
||||
result[normalizedMention] = {
|
||||
providerKey: object.providerKey,
|
||||
objectType: object.objectType,
|
||||
displayKey: object.displayKey,
|
||||
iconKey: object.iconKey,
|
||||
statusCategory: object.statusCategory,
|
||||
liveness: object.liveness,
|
||||
statusLabel: object.statusLabel,
|
||||
statusIconKey: object.statusIconKey,
|
||||
displayTitle: object.displayTitle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
externalObjectDominantCount,
|
||||
externalObjectFallbackTone,
|
||||
externalObjectIconForCategory,
|
||||
externalObjectIconForKey,
|
||||
externalObjectLivenessLabel,
|
||||
externalObjectProviderLabel,
|
||||
externalObjectToneSeverity,
|
||||
@@ -81,6 +82,12 @@ describe("external-objects helpers", () => {
|
||||
expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/);
|
||||
});
|
||||
|
||||
it("maps provider-controlled icon keys through host-owned icons", () => {
|
||||
expect(externalObjectIconForKey("github")?.displayName ?? "").toMatch(/Github/i);
|
||||
expect(externalObjectIconForKey("git-pull-request")?.displayName ?? "").toMatch(/GitPullRequest/i);
|
||||
expect(externalObjectIconForKey("unknown-provider-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("sorts items by severity first, preserving insertion order within a tone", () => {
|
||||
const items = [
|
||||
{ id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false },
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
CircleDot,
|
||||
Clock,
|
||||
CloudOff,
|
||||
GitMerge,
|
||||
Github,
|
||||
GitPullRequest,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
XCircle,
|
||||
@@ -45,6 +48,26 @@ export function externalObjectIconForCategory(category: string): LucideIcon {
|
||||
return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault;
|
||||
}
|
||||
|
||||
const EXTERNAL_OBJECT_ICON_KEYS: Record<string, LucideIcon> = {
|
||||
archive: Archive,
|
||||
check: CheckCircle2,
|
||||
"check-circle": CheckCircle2,
|
||||
circle: Circle,
|
||||
"circle-dot": CircleDot,
|
||||
clock: Clock,
|
||||
github: Github,
|
||||
"git-merge": GitMerge,
|
||||
"git-pull-request": GitPullRequest,
|
||||
key: KeyRound,
|
||||
loader: Loader2,
|
||||
"x-circle": XCircle,
|
||||
};
|
||||
|
||||
export function externalObjectIconForKey(iconKey: string | null | undefined): LucideIcon | null {
|
||||
if (!iconKey) return null;
|
||||
return EXTERNAL_OBJECT_ICON_KEYS[iconKey] ?? null;
|
||||
}
|
||||
|
||||
export function externalObjectIconForLiveness(liveness: string): LucideIcon | null {
|
||||
if (liveness === "auth_required") return KeyRound;
|
||||
if (liveness === "unreachable") return CloudOff;
|
||||
|
||||
Reference in new issue
Block a user