diff --git a/ui/src/components/ExternalObjectPill.tsx b/ui/src/components/ExternalObjectPill.tsx new file mode 100644 index 00000000000..bdd0b6e6c37 --- /dev/null +++ b/ui/src/components/ExternalObjectPill.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, +} from "@paperclipai/shared"; +import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon"; +import { + externalObjectStatusIcon, + externalObjectStatusIconDefault, + externalObjectLivenessOverlay, +} from "../lib/status-colors"; +import { + externalObjectCategoryLabel, + externalObjectLivenessLabel, + externalObjectProviderLabel, + externalObjectTypeLabel, +} from "../lib/external-objects"; +import { cn } from "../lib/utils"; + +export interface ExternalObjectPillData { + providerKey: string | null; + objectType: string | null; + statusCategory: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + displayTitle?: string | null; + statusLabel?: string | null; + url?: string | null; +} + +interface ExternalObjectPillProps { + object: ExternalObjectPillData; + /** Optional external mention count (renders as `×N` superscript when > 1). */ + sourceCount?: number; + /** Optional source-mention summary used as the pill's `title` attribute. */ + sourceSummary?: string | null; + className?: string; + /** Optional rendered label override. Defaults to `provider object-type`. */ + children?: ReactNode; + /** + * If true the pill renders without a hover treatment (used inside + * non-interactive contexts like the property panel). Defaults to false. + */ + inert?: boolean; +} + +/** + * External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip` + * base so external references feel native to readers (Jakob's Law). + */ +export function ExternalObjectPill({ + object, + sourceCount, + sourceSummary, + className, + children, + inert, +}: ExternalObjectPillProps) { + const tone = externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault; + const overlay = externalObjectLivenessOverlay[object.liveness] ?? ""; + const providerLabel = externalObjectProviderLabel(object.providerKey); + const typeLabel = externalObjectTypeLabel(object.objectType); + const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory); + const livenessLabel = externalObjectLivenessLabel(object.liveness); + const ariaLabel = `${providerLabel} ${typeLabel} — ${statusLabel}${ + object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})` + }${object.displayTitle ? `: ${object.displayTitle}` : ""}`; + + 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", + // 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(" "), + overlay, + interactive + && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring", + className, + ); + const titleAttr = sourceSummary + ? `${object.displayTitle ?? `${providerLabel} ${typeLabel}`} — ${sourceSummary}` + : object.displayTitle ?? `${providerLabel} ${typeLabel}`; + const labelText = children ?? ( + <> + {providerLabel.toLowerCase()} + {typeLabel} + + ); + const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? ( + ×{sourceCount} + ) : null; + const innerContent = ( + <> + + + {labelText} + + {countSuffix} + + ); + + if (interactive && object.url) { + return ( + + {innerContent} + + ); + } + + return ( + + {innerContent} + + ); +} diff --git a/ui/src/components/ExternalObjectStatusIcon.tsx b/ui/src/components/ExternalObjectStatusIcon.tsx new file mode 100644 index 00000000000..1cd97b17dcc --- /dev/null +++ b/ui/src/components/ExternalObjectStatusIcon.tsx @@ -0,0 +1,89 @@ +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, +} from "@paperclipai/shared"; +import { Clock } from "lucide-react"; +import { + externalObjectStatusIcon, + externalObjectStatusIconDefault, +} from "../lib/status-colors"; +import { + externalObjectCategoryLabel, + externalObjectIconForCategory, + externalObjectLivenessLabel, +} from "../lib/external-objects"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { cn } from "../lib/utils"; + +interface ExternalObjectStatusIconProps { + category: ExternalObjectStatusCategory; + liveness: ExternalObjectLivenessState; + /** Optional override label used in `aria-label` (e.g. provider-specific copy). */ + label?: string | null; + className?: string; + /** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */ + sizeClassName?: string; + /** + * When true the icon renders inline at 12 px with the `mr-1`/`align` + * adjustments used by `MarkdownIssueLink`. Used by the markdown decorator. + */ + inline?: boolean; +} + +/** + * Pure presentational icon for an external object's status. Combines: + * + * - A category icon (lucide) from `externalObjectIconForCategory`. + * - Tone classes (text + border) from `externalObjectStatusIcon`. + * - An overlaid clock micro-mark when liveness is `stale`. + * - Reduced-motion-aware spinner for the `running` category. + * + * Never mounts plugin React; the host is the sole renderer of identity glyphs. + */ +export function ExternalObjectStatusIcon({ + category, + liveness, + label, + className, + sizeClassName = "h-3.5 w-3.5", + inline = false, +}: ExternalObjectStatusIconProps) { + const reducedMotion = usePrefersReducedMotion(); + const Icon = externalObjectIconForCategory(category); + const tone = externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault; + const livenessSuffix = liveness === "fresh" || liveness === "unknown" + ? "" + : ` (${externalObjectLivenessLabel(liveness)})`; + const ariaLabel = `${label ?? externalObjectCategoryLabel(category)}${livenessSuffix}`; + + // The clock overlay needs a positioned wrapper. Inline mode keeps the icon + // tight to the surrounding text; pill mode expects to size by sizeClassName. + const wrapperBase = inline + ? "relative mr-1 inline-flex shrink-0 align-[-0.125em]" + : "relative inline-flex shrink-0"; + const iconSize = inline ? "h-3 w-3" : sizeClassName; + const isSpinner = category === "running"; + const animateClass = isSpinner && !reducedMotion ? "animate-spin" : ""; + + return ( + + + ); +} diff --git a/ui/src/components/ExternalObjectStatusSummary.tsx b/ui/src/components/ExternalObjectStatusSummary.tsx new file mode 100644 index 00000000000..8b012fc3e35 --- /dev/null +++ b/ui/src/components/ExternalObjectStatusSummary.tsx @@ -0,0 +1,77 @@ +import type { ExternalObjectSummary } from "@paperclipai/shared"; +import { + dominantExternalObjectTone, + externalObjectCategoryLabel, + externalObjectDominantCount, + externalObjectIconForCategory, +} from "../lib/external-objects"; +import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors"; +import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion"; +import { cn } from "../lib/utils"; + +interface ExternalObjectStatusSummaryProps { + summary: ExternalObjectSummary | null | undefined; + /** Compact mode trims everything down to the icon + count, no label text. */ + compact?: boolean; + className?: string; +} + +function dominantCategory(summary: ExternalObjectSummary): string { + // Prefer the first object that matches the highestSeverity tone, since the + // server has already ranked them server-side. + const match = summary.objects.find((object) => object.statusTone === summary.highestSeverity); + return match?.statusCategory ?? "unknown"; +} + +function buildBreakdownTitle(summary: ExternalObjectSummary): string { + const parts: string[] = []; + for (const [category, count] of Object.entries(summary.byStatusCategory)) { + if (!count) continue; + parts.push(`${count} ${externalObjectCategoryLabel(category).toLowerCase()}`); + } + if (summary.staleCount > 0) parts.push(`${summary.staleCount} stale`); + parts.push(`${summary.total} total`); + return `External objects: ${parts.join(", ")}`; +} + +/** + * Compact rollup marker used by sidebar projects and issue list rows. Renders + * the dominant severity icon plus a count badge. Hidden when there are zero + * external objects or every object is in a muted tone. + */ +export function ExternalObjectStatusSummary({ + summary, + compact, + className, +}: ExternalObjectStatusSummaryProps) { + const reducedMotion = usePrefersReducedMotion(); + const tone = dominantExternalObjectTone(summary); + const total = summary?.total ?? 0; + if (!summary || total === 0 || !tone) return null; + + const category = dominantCategory(summary); + const Icon = externalObjectIconForCategory(category); + const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault; + const dominantCount = externalObjectDominantCount(summary); + const title = buildBreakdownTitle(summary); + const animateClass = category === "running" && !reducedMotion ? "animate-spin" : ""; + + return ( + + + ); +} diff --git a/ui/src/hooks/usePrefersReducedMotion.ts b/ui/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 00000000000..88bdd6a611d --- /dev/null +++ b/ui/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +function getInitialValue(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia(QUERY).matches; +} + +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(getInitialValue); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const media = window.matchMedia(QUERY); + const handler = (event: MediaQueryListEvent) => setReduced(event.matches); + setReduced(media.matches); + media.addEventListener("change", handler); + return () => media.removeEventListener("change", handler); + }, []); + + return reduced; +} diff --git a/ui/src/index.css b/ui/src/index.css index 6c5e2b7c581..f3da45a2ce0 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1097,6 +1097,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { white-space: nowrap; } +/* External object URL decorations follow the same pattern: the host renders + the icon prefix, the link text is left untouched (Postel's Law). */ +.paperclip-markdown-external-ref { + display: inline; + white-space: nowrap; +} + .dark .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); } @@ -1206,6 +1213,25 @@ span.paperclip-project-mention-chip { white-space: nowrap; } +/* Touch target floor: WCAG 2.5.5 expects ≥24px on coarse pointers. The base + chip is 22px; bump it on touch viewports without disturbing inline metrics. */ +@media (pointer: coarse) { + a.paperclip-mention-chip, + a.paperclip-project-mention-chip, + span.paperclip-mention-chip, + span.paperclip-project-mention-chip { + min-height: 24px; + } +} + +/* External object pills: variant marker the host renders for known-resolved + external URLs. Inline status icon is rendered by ExternalObjectStatusIcon. */ +a.paperclip-mention-chip[data-mention-kind="external-object"], +span.paperclip-mention-chip[data-mention-kind="external-object"] { + /* Inherit base chip styling; status colour comes from inline classes so the + host can vary tone per category without defining a CSS variant per state. */ +} + /* When the identifier inside a chip is backtick-wrapped in markdown, strip the inline-code monospace/gray styling so the pill label reads cleanly. */ .paperclip-markdown a.paperclip-mention-chip code, diff --git a/ui/src/lib/external-object-href.ts b/ui/src/lib/external-object-href.ts new file mode 100644 index 00000000000..eeb2d3b6606 --- /dev/null +++ b/ui/src/lib/external-object-href.ts @@ -0,0 +1,32 @@ +/** + * Browser-safe URL key for matching markdown hrefs against the + * `external_objects.sanitizedCanonicalUrl` returned by the host API. + * + * The shared/server canonicalizer is the source of truth for the canonical + * URL string, but it imports from `node:crypto` (it also produces an identity + * hash) and therefore cannot run in the browser. We replicate just the URL + * normalization here: + * + * - protocol must be http or https + * - reject userinfo (`username:password@`) + * - lowercase host + * - drop query string + fragment + * - default empty pathname to `/` + */ +export function normalizeExternalObjectHref(value: string | null | undefined): string | null { + if (!value) return null; + + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + + const scheme = url.protocol === "https:" ? "https" : "http"; + const path = url.pathname || "/"; + return `${scheme}://${url.host.toLowerCase()}${path}`; +} diff --git a/ui/src/lib/external-objects.ts b/ui/src/lib/external-objects.ts new file mode 100644 index 00000000000..ca7bb558c14 --- /dev/null +++ b/ui/src/lib/external-objects.ts @@ -0,0 +1,211 @@ +import { + AlertCircle, + AlertOctagon, + Archive, + CheckCircle2, + Circle, + CircleDashed, + CircleDot, + Clock, + CloudOff, + KeyRound, + Loader2, + XCircle, + type LucideIcon, +} from "lucide-react"; +import type { + ExternalObjectLivenessState, + ExternalObjectStatusCategory, + ExternalObjectStatusTone, + ExternalObjectSummary, + ExternalObjectSummaryItem, +} from "@paperclipai/shared"; + +/** + * Lucide icon for each status category. The mapping is host-owned per the + * Phase 1B security review — providers never inject inline React. + */ +export const externalObjectCategoryIcon: Record = { + unknown: CircleDashed, + open: CircleDot, + waiting: Clock, + running: Loader2, + succeeded: CheckCircle2, + failed: XCircle, + blocked: AlertOctagon, + closed: Circle, + archived: Archive, + auth_required: KeyRound, + unreachable: CloudOff, +}; + +export const externalObjectCategoryIconDefault: LucideIcon = CircleDashed; + +export function externalObjectIconForCategory(category: string): LucideIcon { + return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault; +} + +export function externalObjectIconForLiveness(liveness: string): LucideIcon | null { + if (liveness === "auth_required") return KeyRound; + if (liveness === "unreachable") return CloudOff; + return null; +} + +const CATEGORY_LABELS: Record = { + unknown: "Not yet resolved", + open: "Open", + waiting: "Waiting", + running: "Running", + succeeded: "Succeeded", + failed: "Failed", + blocked: "Blocked", + closed: "Closed", + archived: "Archived", + auth_required: "Authorization required", + unreachable: "Unreachable", +}; + +export function externalObjectCategoryLabel(category: string): string { + return CATEGORY_LABELS[category] ?? category.replace(/_/g, " "); +} + +const LIVENESS_LABELS: Record = { + unknown: "Not yet refreshed", + fresh: "Fresh", + stale: "Stale", + auth_required: "Requires auth", + unreachable: "Unreachable", +}; + +export function externalObjectLivenessLabel(liveness: string): string { + return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " "); +} + +/** + * Higher number = more attention-worthy. The rollups in §5 sort by tone first. + * Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`. + */ +const TONE_SEVERITY: Record = { + muted: 0, + neutral: 1, + success: 2, + info: 3, + warning: 4, + danger: 5, +}; + +export function externalObjectToneSeverity(tone: string | null | undefined): number { + if (!tone) return 0; + return TONE_SEVERITY[tone] ?? 0; +} + +const CATEGORY_TONE_FALLBACK: Record = { + unknown: "muted", + open: "info", + waiting: "warning", + running: "info", + succeeded: "success", + failed: "danger", + blocked: "danger", + closed: "muted", + archived: "muted", + auth_required: "warning", + unreachable: "danger", +}; + +export function externalObjectFallbackTone( + category: ExternalObjectStatusCategory, +): ExternalObjectStatusTone { + return CATEGORY_TONE_FALLBACK[category] ?? "neutral"; +} + +const PROVIDER_LABELS: Record = { + github: "GitHub", + github_pull_request: "GitHub", + github_issue: "GitHub", + hubspot: "HubSpot", + linear: "Linear", + jira: "Jira", + notion: "Notion", + asana: "Asana", +}; + +export function externalObjectProviderLabel(providerKey: string | null | undefined): string { + if (!providerKey) return "External"; + const lookup = PROVIDER_LABELS[providerKey]; + if (lookup) return lookup; + return providerKey + .split(/[._-]/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +const OBJECT_TYPE_LABELS: Record = { + pull_request: "pull request", + issue: "issue", + deployment: "deployment", + workflow_run: "workflow run", + ticket: "ticket", + lead: "lead", +}; + +export function externalObjectTypeLabel(objectType: string | null | undefined): string { + if (!objectType) return "object"; + return OBJECT_TYPE_LABELS[objectType] ?? objectType.replace(/_/g, " "); +} + +/** + * Sort summary items by severity-first ordering: danger → warning → info → + * success → muted/neutral. Within a tone, items keep their incoming order so + * server-side ordering (e.g. most recent change first) is preserved. + */ +export function sortExternalObjectsBySeverity( + items: readonly T[], +): T[] { + return [...items] + .map((item, index) => ({ item, index })) + .sort((a, b) => { + const aTone = externalObjectToneSeverity(a.item.statusTone); + const bTone = externalObjectToneSeverity(b.item.statusTone); + if (aTone !== bTone) return bTone - aTone; + return a.index - b.index; + }) + .map(({ item }) => item); +} + +/** + * Compute the dominant tone in a summary — used by sidebar / list rollups. + * Falls back to `null` when no objects are present or every tone is `muted`. + */ +export function dominantExternalObjectTone( + summary: Pick | null | undefined, +): ExternalObjectStatusTone | null { + if (!summary) return null; + const tone = summary.highestSeverity; + if (!tone) return null; + if (externalObjectToneSeverity(tone) <= TONE_SEVERITY.muted) return null; + return tone; +} + +/** + * For the sidebar / list rollup we want the count of objects matching the + * dominant severity (e.g. "3 failed PRs"), not the global total. + */ +export function externalObjectDominantCount( + summary: Pick | null | undefined, +): number { + if (!summary) return 0; + const tone = summary.highestSeverity; + if (!tone) return 0; + return summary.objects.filter((object) => object.statusTone === tone).length; +} + +/** + * Reduced motion support — match `prefers-reduced-motion: reduce` so the + * spinning Loader2 stays static when requested. Hooks consume this via React + * to react to runtime changes; non-hook callers can use the helper directly. + */ +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || !window.matchMedia) return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts index bcedcedca75..267ade68303 100644 --- a/ui/src/lib/status-colors.ts +++ b/ui/src/lib/status-colors.ts @@ -217,3 +217,86 @@ export const priorityColor: Record = { }; export const priorityColorDefault = "text-yellow-600 dark:text-yellow-400"; + +// --------------------------------------------------------------------------- +// External object status — colors & severity ranking +// --------------------------------------------------------------------------- +// +// Categories come from `EXTERNAL_OBJECT_STATUS_CATEGORIES` in @paperclipai/shared. +// The map keys here intentionally mirror the union — keep them in sync. +// +// Tone reuse rationale (see UX spec §1): +// unknown → backlog hue (muted, dashed circle) +// open → todo / blue +// waiting → amber (distinct from internal in_progress yellow) +// running → cyan, animated when motion is allowed +// succeeded → done / green +// failed → red +// blocked → red +// closed → muted neutral +// archived → muted neutral +// auth_required → amber + dashed +// unreachable → red + dashed + +export const externalObjectStatusIcon: Record = { + unknown: "text-muted-foreground border-muted-foreground", + open: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400", + waiting: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400", + running: "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400", + succeeded: "text-green-600 border-green-600 dark:text-green-400 dark:border-green-400", + failed: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", + blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", + closed: "text-neutral-500 border-neutral-500", + archived: "text-neutral-500 border-neutral-500", + auth_required: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400", + unreachable: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400", +}; + +export const externalObjectStatusIconDefault = "text-muted-foreground border-muted-foreground"; + +export const externalObjectStatusBadge: Record = { + unknown: "bg-muted text-muted-foreground", + open: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300", + waiting: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + running: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300", + succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", + failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", + closed: "bg-muted text-muted-foreground", + archived: "bg-muted text-muted-foreground", + auth_required: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300", + unreachable: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300", +}; + +export const externalObjectStatusBadgeDefault = "bg-muted text-muted-foreground"; + +/** + * Liveness overlay applied on top of the base status tone. We deliberately + * encode it as utility classes (not a tone change) so callers can append the + * overlay to any pill, icon, or marker without redefining colors. + * + * The dashed border + reduced opacity guarantees a non-color differentiator + * for stale / auth_required / unreachable per WCAG 1.4.1. + */ +export const externalObjectLivenessOverlay: Record = { + unknown: "", + fresh: "", + stale: "opacity-70 [border-style:dashed]", + auth_required: "[border-style:dashed]", + unreachable: "[border-style:dashed]", +}; + +/** + * Severity ranking used by sidebar/list rollups. Higher number = more + * attention-worthy. Anything ≤ `muted` should be hidden when summarising. + */ +export const externalObjectStatusToneSeverity: Record = { + muted: 0, + neutral: 1, + success: 2, + info: 3, + warning: 4, + danger: 5, +}; + +export const externalObjectStatusToneSeverityDefault = 0;