import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { Check } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { IssueReferencePill } from "@/components/IssueReferencePill"; import { Link, useCaseHref } from "@/lib/router"; import { copyTextToClipboard } from "@/lib/clipboard"; import { cn } from "@/lib/utils"; // ----------------------------------------------------------------------------- // CaseFieldsPanel (PAP-12968 §3) — the generic key-value renderer for a case's // `fields` JSON blob. The server stores arbitrary agent-authored JSON, so the UI // renders by *value type* (Postel's law: never crash on unexpected shapes) and // preserves the skill's key insertion order (does NOT alphabetize). // ----------------------------------------------------------------------------- const URL_RE = /^https?:\/\/\S+$/i; const CASE_ID_RE = /^[A-Z][A-Z0-9]*-C\d+$/; const ISSUE_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/; const ISSUE_ID_IN_TEXT_RE = /\b[A-Z][A-Z0-9]*-\d+\b/g; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** A muted em-dash for null / empty / missing values. */ function EmptyValue() { return —; } function isIssueIdentifierField(fieldKey: string | undefined): boolean { if (!fieldKey) return false; const normalized = fieldKey.toLowerCase().replace(/[^a-z0-9]/g, ""); return normalized.includes("issueidentifier") || normalized.includes("taskidentifier"); } function stringifyCopyValue(value: unknown): string { if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return JSON.stringify(value, null, 2); } function extractIssueIdentifiers(value: unknown, fieldKey?: string): string[] { if (!isIssueIdentifierField(fieldKey)) { return typeof value === "string" && ISSUE_ID_RE.test(value.trim()) ? [value.trim()] : []; } const identifiers: string[] = []; const add = (candidate: unknown) => { if (typeof candidate !== "string") return; for (const match of candidate.matchAll(ISSUE_ID_IN_TEXT_RE)) identifiers.push(match[0]); }; if (Array.isArray(value)) value.forEach(add); else add(value); return [...new Set(identifiers)]; } function IssueIdentifierValue({ identifiers }: { identifiers: string[] }) { return ( {identifiers.map((identifier) => ( ))} ); } function CopyableCompactValue({ value, children, className, }: { value: unknown; children: ReactNode; className?: string; }) { const text = stringifyCopyValue(value); const [copied, setCopied] = useState(false); const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(() => { void copyTextToClipboard(text).then(() => { setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); }); }, [text]); return ( {copied ? ( Copied ) : null} ); } function StringValue({ value, variant }: { value: string; variant: "compact" | "full" }) { const caseHref = useCaseHref(); const trimmed = value.trim(); if (trimmed === "") return ; if (URL_RE.test(trimmed)) { return ( {trimmed} ↗ ); } if (CASE_ID_RE.test(trimmed)) { return ( {trimmed} ); } if (variant === "compact") { return ( {value} ); } return {value}; } export function CaseFieldValue({ value, fieldKey, variant = "full", }: { value: unknown; fieldKey?: string; variant?: "compact" | "full"; }) { if (value === null || value === undefined) return ; const issueIdentifiers = extractIssueIdentifiers(value, fieldKey); if (issueIdentifiers.length > 0) return ; if (typeof value === "string") return ; if (typeof value === "number") { if (!Number.isFinite(value)) return {String(value)}; if (variant === "compact") { return ( {value.toLocaleString()} ); } return {value.toLocaleString()}; } if (typeof value === "boolean") { return value ? ( ) : ( ); } if (Array.isArray(value)) { if (value.length === 0) return ; if (variant === "compact") { return ( {stringifyCopyValue(value)} ); } return (
{value.map((item, index) => ( {typeof item === "string" || typeof item === "number" || typeof item === "boolean" ? String(item) : JSON.stringify(item)} ))}
); } if (isPlainObject(value)) { const snippet = JSON.stringify(value); if (variant === "compact") { return ( {snippet} ); } return (
        {JSON.stringify(value, null, 2)}
      
); } return {String(value)}; } type CaseFieldRow = { id: string; path: string; value: unknown; }; function flattenCaseFields(fields: Record): CaseFieldRow[] { const rows: CaseFieldRow[] = []; const visit = (value: unknown, path: string, segments: (string | number)[]) => { if (isPlainObject(value)) { const entries = Object.entries(value); if (entries.length > 0) { entries.forEach(([key, child]) => visit(child, `${path}.${key}`, [...segments, key])); return; } } else if (Array.isArray(value) && value.some((item) => typeof item === "object" && item !== null)) { // A single record reads naturally as items.quantity. Keep indices for // multiple records or mixed arrays so separate entries stay identifiable. if (value.length === 1 && isPlainObject(value[0])) { visit(value[0], path, [...segments, 0]); } else { value.forEach((child, index) => visit(child, `${path}[${index}]`, [...segments, index])); } return; } // Structural identity stays distinct even if literal keys contain dots or // brackets that happen to look like another field's displayed path. rows.push({ id: JSON.stringify(segments), path, value }); }; Object.entries(fields).forEach(([key, value]) => visit(value, key, [key])); return rows; } function TableStringValue({ value, fieldKey, quoted = false, }: { value: string; fieldKey: string; quoted?: boolean; }) { const caseHref = useCaseHref(); const trimmed = value.trim(); const text = quoted ? JSON.stringify(value).slice(1, -1) : value; const linkClass = "text-primary underline-offset-2 hover:underline"; let content: ReactNode = text; if (URL_RE.test(trimmed)) { content = {text}; } else if (CASE_ID_RE.test(trimmed)) { content = {text}; } else if (ISSUE_ID_RE.test(trimmed)) { content = {text}; } else if (isIssueIdentifierField(fieldKey)) { // Keep prose around task references rather than replacing the entire value // with only the identifiers extracted from it. const identifiers = new Set(extractIssueIdentifiers(value, fieldKey)); content = text.split(/(\b[A-Z][A-Z0-9]*-\d+\b)/g).map((part, index) => ( identifiers.has(part) ? {part} : part )); } if (quoted) return <>{'"'}{content}{'"'}; return value === "" ? '""' : content; } function TableFieldValue({ value, fieldKey }: { value: unknown; fieldKey: string }) { if (value === null || value === undefined) return ; if (typeof value === "string") return ; if (Array.isArray(value)) { return ( <> {"["} {value.map((item, index) => ( {index > 0 ? "," : null} {typeof item === "string" ? : JSON.stringify(item)} ))} {"]"} ); } if (typeof value === "object") return JSON.stringify(value); return String(value); } /** Compact read-only field rows for case detail, including nested JSON paths. */ export function CaseFieldsTable({ fields }: { fields: Record }) { const rows = flattenCaseFields(fields); if (rows.length === 0) { return

No fields set

; } return ( {rows.map(({ id, path, value }) => ( ))}
{path}
); } export function CaseFieldsPanel({ fields }: { fields: Record }) { const entries = Object.entries(fields ?? {}); return (

Fields

from the skill's schema — rendered generically
{entries.length === 0 ? (
No fields set
) : (
{entries.map(([key, value]) => (
{key}
))}
)}
); }