{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "escalation-card",
  "title": "Escalation Card",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The moment an AI agent hands a conversation to a person: who is waiting and for how long, why, how the customer seems to feel in words, what the agent understood, the last messages, an editable suggested reply, and Take over and Snooze.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/escalation-card.tsx",
      "content": "// Ballmac UI: Escalation Card. https://ui.ballmac.com/components/escalation-card\n\"use client\"\n\nimport * as React from \"react\"\nimport { BellOff, Clock, HandHelping, Loader2, Send, Sparkles } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype Sentiment = \"calm\" | \"frustrated\" | \"angry\" | \"unknown\"\n\ntype EscalationMessage = {\n  from: \"customer\" | \"agent\"\n  text: string\n  /** When it was sent, already formatted, such as \"2:11 pm\". */\n  time?: string\n}\n\ntype EscalationCardProps = Omit<React.ComponentProps<\"article\">, \"title\"> & {\n  customer: string\n  /** Where the conversation is happening, such as \"Chat\" or \"Email\". */\n  channel: string\n  /** Why the agent is handing it to a person, in a sentence. */\n  reason: string\n  sentiment?: Sentiment\n  /** How long they have been waiting, already formatted, such as \"4 min\". */\n  waiting?: string\n  /** What the agent understood so far, as short points. */\n  summary?: string[]\n  /** The last few messages. */\n  messages?: EscalationMessage[]\n  /** A reply the agent suggests, which the person can edit before sending. */\n  suggestedReply?: string\n  /** Called when the person takes the conversation over. */\n  onTakeOver?: () => void | Promise<void>\n  /** Called to snooze it for a while. */\n  onSnooze?: () => void | Promise<void>\n  /** Called with the (possibly edited) reply when the person sends it. */\n  onSendReply?: (reply: string) => void | Promise<void>\n}\n\nconst SENTIMENT: Record<Sentiment, string> = { calm: \"Calm\", frustrated: \"Frustrated\", angry: \"Angry\", unknown: \"Unknown\" }\n\n/**\n * The moment an AI agent hands a conversation to a person: who is waiting and for how long, why it was handed over, how the customer seems to feel (in words),\n * what the agent understood, the last messages, and a suggested reply the person can edit and send. Take over and Snooze report back, and a failure is said plainly.\n */\nfunction EscalationCard({ customer, channel, reason, sentiment = \"unknown\", waiting, summary = [], messages = [], suggestedReply = \"\", onTakeOver, onSnooze, onSendReply, className, ...props }: EscalationCardProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  const [reply, setReply] = React.useState(suggestedReply)\n  const [busy, setBusy] = React.useState<\"take\" | \"snooze\" | \"send\" | null>(null)\n  const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const [done, setDone] = React.useState<string | null>(null)\n\n  async function run(kind: \"take\" | \"snooze\" | \"send\", work: () => void | Promise<void>, ok: string) {\n    setBusy(kind)\n    setResult(null)\n    try {\n      await work()\n      setDone(kind)\n      setResult({ ok: true, text: ok })\n    } catch (e) {\n      setResult({ ok: false, text: e instanceof Error ? e.message : msg(\"escalation-card.failed\", \"That did not go through. Nothing was changed.\") })\n    } finally {\n      setBusy(null)\n    }\n  }\n  const sentimentText = msg(`escalation-card.sentiment.${sentiment}`, SENTIMENT[sentiment])\n  const finished = done !== null && done !== \"send\"\n\n  return (\n    <article data-slot=\"escalation-card\" data-sentiment={sentiment} aria-labelledby={`${uid}-t`} className={cn(\"grid w-full gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", sentiment === \"angry\" && \"border-destructive/50\", className)} {...props}>\n      <header className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-2\">\n        <div className=\"min-w-0\">\n          <h3 id={`${uid}-t`} className=\"text-base font-semibold tracking-tight\">{customer}</h3>\n          <p className=\"text-xs text-muted-foreground\">{channel}</p>\n        </div>\n        <p className=\"flex flex-wrap items-center gap-2 text-xs\">\n          <span className={cn(\"rounded-full border px-2.5 py-0.5 font-medium\", sentiment === \"angry\" && \"border-destructive/60 text-destructive\")}>\n            <span className=\"sr-only\">{msg(\"escalation-card.feels\", \"Seems\")} </span>{sentimentText}\n          </span>\n          {waiting && (\n            <span className=\"flex items-center gap-1.5 tabular-nums text-muted-foreground\">\n              <Clock aria-hidden=\"true\" className=\"size-3.5\" /> {msg(\"escalation-card.waiting\", \"Waiting {time}\", { time: waiting })}\n            </span>\n          )}\n        </p>\n      </header>\n\n      <p className=\"flex gap-2 rounded-lg bg-muted/50 px-3 py-2.5 text-[13px] leading-snug\">\n        <HandHelping aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n        <span><span className=\"font-medium\">{msg(\"escalation-card.why\", \"Why a person is needed:\")}</span> {reason}</span>\n      </p>\n\n      {summary.length > 0 && (\n        <section aria-label={msg(\"escalation-card.summary\", \"What the agent understood\")}>\n          <p className=\"flex items-center gap-1.5 text-xs font-medium text-muted-foreground\"><Sparkles aria-hidden=\"true\" className=\"size-3.5\" /> {msg(\"escalation-card.summary\", \"What the agent understood\")}</p>\n          <ul className=\"mt-2 grid gap-1.5 text-[13px] leading-snug\">\n            {summary.map((s) => (\n              <li key={s} className=\"flex gap-2\"><span aria-hidden=\"true\" className=\"mt-[0.45rem] size-1 shrink-0 rounded-full bg-foreground\" />{s}</li>\n            ))}\n          </ul>\n        </section>\n      )}\n\n      {messages.length > 0 && (\n        <section aria-label={msg(\"escalation-card.messages\", \"Last messages\")}>\n          <ul className=\"grid gap-2\">\n            {messages.map((m, i) => (\n              <li key={i} className={cn(\"max-w-[85%] rounded-xl px-3 py-2 text-[13px] leading-snug\", m.from === \"customer\" ? \"justify-self-start bg-muted\" : \"justify-self-end border bg-background\")}>\n                <span className=\"mb-0.5 block text-[11px] font-medium text-muted-foreground\">\n                  {m.from === \"customer\" ? customer : msg(\"escalation-card.agent\", \"Agent\")}{m.time ? ` · ${m.time}` : \"\"}\n                </span>\n                {m.text}\n              </li>\n            ))}\n          </ul>\n        </section>\n      )}\n\n      {suggestedReply !== \"\" && !finished && (\n        <div className=\"grid gap-1.5\">\n          <label htmlFor={`${uid}-reply`} className=\"text-xs font-medium text-muted-foreground\">{msg(\"escalation-card.reply\", \"Suggested reply, edit before sending\")}</label>\n          <textarea id={`${uid}-reply`} value={reply} onChange={(e) => setReply(e.target.value)} rows={4} className=\"min-h-24 w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\" />\n        </div>\n      )}\n\n      {!finished && (\n        <div className=\"flex flex-wrap items-center justify-end gap-2 border-t pt-4\">\n          <button type=\"button\" disabled={busy !== null} onClick={() => void run(\"snooze\", () => onSnooze?.(), msg(\"escalation-card.snoozed\", \"Snoozed. It will come back if the customer writes again.\"))} className=\"inline-flex h-9 items-center gap-1.5 rounded-full border bg-background px-3.5 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\">\n            <BellOff aria-hidden=\"true\" className=\"size-4\" /> {msg(\"escalation-card.snooze\", \"Snooze\")}\n          </button>\n          {suggestedReply !== \"\" && (\n            <button type=\"button\" disabled={busy !== null || !reply.trim()} onClick={() => void run(\"send\", () => onSendReply?.(reply.trim()), msg(\"escalation-card.sent\", \"Reply sent to {customer}.\", { customer }))} className=\"inline-flex h-9 items-center gap-1.5 rounded-full border bg-background px-3.5 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\">\n              {busy === \"send\" ? <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" /> : <Send aria-hidden=\"true\" className=\"size-4 rtl:-scale-x-100\" />} {msg(\"escalation-card.send\", \"Send reply\")}\n            </button>\n          )}\n          <button type=\"button\" disabled={busy !== null} onClick={() => void run(\"take\", () => onTakeOver?.(), msg(\"escalation-card.taken\", \"You have taken over from the agent.\"))} className=\"inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none\">\n            {busy === \"take\" && <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />} {msg(\"escalation-card.take\", \"Take over\")}\n          </button>\n        </div>\n      )}\n      <p aria-live=\"polite\" role={result && !result.ok ? \"alert\" : undefined} className={cn(\"text-sm\", result && !result.ok ? \"font-medium text-destructive\" : \"text-muted-foreground\")}>{result?.text}</p>\n    </article>\n  )\n}\n\nexport { EscalationCard, type EscalationCardProps, type EscalationMessage, type Sentiment }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/escalation-card.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "support",
      "handoff",
      "escalation",
      "human in the loop",
      "inbox",
      "customer service"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "customer, channel, reason (required); sentiment calm | frustrated | angry | unknown, waiting, summary string[], messages [{ from: 'customer' | 'agent', text, time? }], suggestedReply (editable). onTakeOver, onSnooze and onSendReply(reply) may return promises; each reports success in words and shows a failure as an alert.",
      "whenToUse": [
        "Showing a person a conversation an agent could not finish, with context and a head start",
        "An inbox row that needs a human decision"
      ],
      "whenNotToUse": [
        "Approving a tool call; use approval-card",
        "The packet passed between two agents; use handoff-card"
      ],
      "composesWith": [
        "handoff-card",
        "approval-card",
        "agent-roster",
        "ai-chat"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "An article named by the customer; how they seem to feel is read as a word ('Seems frustrated'); results are a polite live region and failures an alert"
        },
        {
          "keys": "Tab",
          "action": "The suggested reply, then Snooze, Send reply and Take over"
        },
        {
          "keys": "Sentiment",
          "action": "Always a word in a pill; red only reinforces 'Angry'"
        }
      ],
      "customization": [
        "sentiment and waiting",
        "summary and messages",
        "suggestedReply and onSendReply",
        "onTakeOver and onSnooze"
      ]
    },
    "statesNone": "A presentational card with its own actions: the conversation is passed in, so it has nothing of its own to load, and failures are reported on the card.",
    "examples": [
      "escalation-card-demo",
      "escalation-card-simple"
    ],
    "url": "https://ui.ballmac.com/components/escalation-card"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}