Allow external objects to define presentation metadata

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:05 +00:00
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,
+4 -3
View File
@@ -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.
+8
View File
@@ -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",
+21
View File
@@ -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", () => {
+35 -12
View File
@@ -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);
+9 -4
View File
@@ -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");
});
+9
View File
@@ -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,
};
}
+7
View File
@@ -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 },
+23
View File
@@ -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;