# Customer Support Copilot

> Grounded reply drafts with citations, honest low-confidence answers, inline corrections, an approval gate on refunds, and a rating row on every draft.

A shadcn/ui block from the [@scrimui](https://scrimui.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/scrimui/support-copilot).

- Page, with a live preview: https://shadcn.exchange/r/scrimui/support-copilot
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/scrimui/support-copilot-light-f3a5808633a2876b.png
- Registry item: https://scrimui.dev/r/support-copilot.json
- Categories: pattern
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://scrimui.dev/r/support-copilot.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://scrimui.dev/r/conversation-sidebar.json`, `https://scrimui.dev/r/context-picker.json`, `https://scrimui.dev/r/citation-ui.json`, `https://scrimui.dev/r/source-list.json`, `https://scrimui.dev/r/confidence-answer.json`, `https://scrimui.dev/r/inline-correction.json`, `https://scrimui.dev/r/approval-request.json`, `https://scrimui.dev/r/response-rating.json`, `https://scrimui.dev/r/streaming-message.json`, `https://scrimui.dev/r/prompt-input.json`, `card`

## Notes from the registry

https://scrimui.dev/patterns/support-copilot

## Files

### `src/showcase/patterns/support-copilot/support-copilot.tsx`

```tsx
"use client";

import { Card } from "@/components/ui/card";
import * as React from "react";
import { ConversationSidebar, type ConversationGroup } from "@/components/ui/conversation-sidebar";
import { ContextPicker, type ContextItem } from "@/components/ui/context-picker";
import { CitationList, type Citation } from "@/components/ui/citation-ui";
import { SourceList, type RetrievedSource } from "@/components/ui/source-list";
import { ConfidenceAnswer } from "@/components/ui/confidence-answer";
import { InlineCorrection } from "@/components/ui/inline-correction";
import { ApprovalRequest, type ApprovalState } from "@/components/ui/approval-request";
import { ResponseRating, type Rating } from "@/components/ui/response-rating";
import { StreamingMessage } from "@/components/ui/streaming-message";
import { PromptInput } from "@/components/ui/prompt-input";

/**
 * A support copilot: the agent talks to customers, the copilot drafts the
 * grounded answers — and knows when to shut up.
 *
 * What this pattern exists to show:
 *
 * 1. **Grounded answers are inspectable.** Citations under the draft, the
 *    retrieved passages behind one disclosure, scores and the floor visible.
 * 2. **Low confidence is said out loud.** The second answer admits it might
 *    be mixing up legacy terms — and offers the exact thing to check.
 * 3. **Corrections feed the copilot, not the void.** The agent fixes the
 *    wrong fact inline; the original stays struck through as training data.
 * 4. **Money moves wait for a human.** The refund is drafted, but sending it
 *    is an approval gate — approve and deny both leave a receipt.
 * 5. **Every draft is rated.** Thumbs and reason chips are how the team
 *    learns which topics the copilot handles badly.
 */

/* ------------------------------------------------------------------ */
/* Mock data                                                           */
/* ------------------------------------------------------------------ */

const SIDEBAR_GROUPS: ConversationGroup[] = [
  {
    id: "today",
    label: "Today",
    conversations: [
      { id: "t1042", title: "#1042 Refund request — Ana R.", updatedAt: "2m", pinned: true },
      { id: "t1041", title: "#1041 Invoice copy — D. Kim", updatedAt: "26m" },
      { id: "t1040", title: "#1040 Export fails on Safari", updatedAt: "1h" },
    ],
  },
  {
    id: "week",
    label: "Earlier this week",
    conversations: [
      { id: "t1039", title: "#1039 Plan downgrade question", updatedAt: "Tue" },
      { id: "t1038", title: "#1038 SSO setup help", updatedAt: "Mon" },
    ],
  },
];

const CONTEXT_SOURCES: ContextItem[] = [
  { id: "help", kind: "knowledge", title: "Help Center", detail: "214 articles", tokens: 0, recent: true },
  { id: "policy", kind: "file", title: "refund-policy-2026.pdf", detail: "12 pages", tokens: 3_800, recent: true },
  { id: "order", kind: "app", title: "Order #8182 — Billing", detail: "Stripe dashboard", tokens: 900 },
  { id: "wiki", kind: "knowledge", title: "Internal support wiki", detail: "Notion", status: "permission-required", tokens: 0 },
  { id: "macros", kind: "app", title: "Saved reply macros", detail: "38 macros", status: "unavailable" },
];

const CITATIONS: Citation[] = [
  {
    id: 1,
    title: "Refund policy — Help Center",
    url: "https://help.example.com/refunds",
    snippet: "Customers may request a full refund within 30 days of the charge date.",
  },
  {
    id: 2,
    title: "Refund policy §2.4",
    url: "https://help.example.com/refunds#timing",
    snippet: "Refunds are issued to the original payment method within 5–10 business days.",
  },
];

const RETRIEVED: RetrievedSource[] = [
  { id: "s1", title: "Refund policy §2.1", passage: "Customers may request a full refund within 30 days of the charge date, no questions asked.", score: 0.86 },
  { id: "s2", title: "Refund policy §2.4", passage: "Refunds are issued to the original payment method within 5–10 business days.", score: 0.74 },
  { id: "s3", title: "2023 policy archive", passage: "Legacy terms allowed a 14-day window for annual plans purchased before 2025.", score: 0.41 },
];

const ANSWER_CITED =
  "Ana is well inside the window: order #8182 was charged 18 days ago and the policy allows a full refund within 30 days [1]. You can approve this one — the money returns to her card in 5–10 business days [2].";

const ANSWER_LOW =
  "Her plan may still be under legacy terms. The 2023 archive mentions a 14-day window for annual plans purchased before 2025 — if that applies, this refund is 4 days too late.";

const HEDGE_LOW = "Check the plan's purchase date before quoting a window — the 2023 terms said 14 days, and I can't tell from here whether she renewed under the 2026 policy.";

const ANSWER_APPROVAL =
  "I've drafted the refund for order #8182: $48.20 back to card •• 4242. It clears the 30-day window with room to spare. Approve below and it goes out now.";

const GENERIC_ANSWER =
  "Based on the ticket history, Ana has been a customer since 2024 with no prior refunds. A short, warm confirmation with the 5–10 day timeline usually closes these well.";

/* ------------------------------------------------------------------ */
/* Pattern                                                             */
/* ------------------------------------------------------------------ */

type Turn = {
  id: string;
  role: "agent" | "copilot";
  text: string;
  kind?: "cited" | "low" | "approval" | "generic";
  streaming?: boolean;
};

export function SupportCopilotPattern() {
  const [turns, setTurns] = React.useState<Turn[]>([]);
  const [submits, setSubmits] = React.useState(0);
  const [ratings, setRatings] = React.useState<Record<string, { rating?: Rating; submitted?: boolean }>>({});
  const [approval, setApproval] = React.useState<ApprovalState>("pending");
  const [correction, setCorrection] = React.useState<string | undefined>();
  const [contextSel, setContextSel] = React.useState<string[]>(["help", "policy"]);
  const [granted, setGranted] = React.useState<string[]>([]);

  const streaming = turns.some((t) => t.streaming);

  function submit(text: string) {
    if (streaming) return;
    const n = submits + 1;
    setSubmits(n);
    const kind: Turn["kind"] = n === 1 ? "cited" : n === 2 ? "low" : n === 3 ? "approval" : "generic";
    const answer =
      kind === "cited" ? ANSWER_CITED : kind === "low" ? ANSWER_LOW : kind === "approval" ? ANSWER_APPROVAL : GENERIC_ANSWER;
    setTurns((ts) => [
      ...ts,
      { id: `a${n}`, role: "agent", text },
      { id: `c${n}`, role: "copilot", text: answer, kind, streaming: true },
    ]);
  }

  function finishStreaming(id: string) {
    setTurns((ts) => ts.map((t) => (t.id === id ? { ...t, streaming: false } : t)));
  }

  function rate(id: string, rating: Rating | undefined) {
    setRatings((r) => ({ ...r, [id]: { ...r[id], rating, submitted: rating ? r[id]?.submitted : false } }));
  }

  function submitDetail(id: string) {
    setRatings((r) => ({ ...r, [id]: { ...r[id], submitted: true } }));
  }

  const contextItems = CONTEXT_SOURCES.map((item) =>
    granted.includes(item.id) ? { ...item, status: "available" as const } : item,
  );

  return (
    <Card className="flex-row gap-0 py-0 flex h-[640px] overflow-hidden rounded-xl border border-border bg-card">
      {/* Ticket rail */}
      <aside className="hidden w-56 shrink-0 border-r border-border md:block">
        <ConversationSidebar
          groups={SIDEBAR_GROUPS}
          activeId="t1042"
          newChatLabel="New ticket"
          searchPlaceholder="Search tickets…"
          className="h-full"
        />
      </aside>

      {/* Copilot thread */}
      <div className="flex min-w-0 flex-1 flex-col">
        <div className="border-b border-border px-4 py-3">
          <p className="text-sm font-semibold text-foreground">Ticket #1042 — Refund request</p>
          <p className="truncate text-xs text-muted-foreground">
            Customer: Ana R. · Pro plan since 2024 · Order #8182 · “I was charged but already cancelled”
          </p>
        </div>

        <div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
          {turns.length === 0 && (
            <div className="rounded-xl border border-dashed border-border px-4 py-6 text-center">
              <p className="text-sm font-medium text-foreground">Paste the customer&#39;s message to get a grounded draft</p>
              <p className="mt-1 text-xs text-muted-foreground">
                Answers cite the Help Center and refund policy — the copilot says when it&#39;s guessing.
              </p>
            </div>
          )}

          {turns.map((turn) =>
            turn.role === "agent" ? (
              <div key={turn.id} className="flex justify-end">
                <p className="max-w-[85%] rounded-xl rounded-br-md bg-primary px-4 py-2.5 text-sm leading-6 text-primary-foreground">
                  {turn.text}
                </p>
              </div>
            ) : turn.kind === "low" && !turn.streaming ? (
              /* Low-confidence drafts keep their own honest surface. */
              <div key={turn.id} className="space-y-2">
                <ConfidenceAnswer confidence="low" text={turn.text} hedge={HEDGE_LOW} />
                <div className="ml-1 space-y-1">
                  <p className="text-xs font-medium text-muted-foreground">
                    Correct the copilot
                  </p>
                  <InlineCorrection
                    text="Legacy annual plans have a 14-day refund window."
                    correction={correction}
                    correctedBy="you"
                    onSubmit={(c) => setCorrection(c)}
                    onRevert={() => setCorrection(undefined)}
                  />
                </div>
                <ResponseRating
                  rating={ratings[turn.id]?.rating}
                  submitted={ratings[turn.id]?.submitted}
                  onRate={(r) => rate(turn.id, r)}
                  onSubmitDetail={() => submitDetail(turn.id)}
                  reasons={["Wrong policy", "Outdated terms", "Missed the question", "Too cautious"]}
                />
              </div>
            ) : (
              <div key={turn.id} className="space-y-2">
                <StreamingMessage
                  text={turn.text}
                  isStreaming={turn.streaming}
                  showActions={false}
                  speed={14}
                  onComplete={() => finishStreaming(turn.id)}
                />
                {!turn.streaming && (
                  <>
                    {turn.kind === "cited" && (
                      <>
                        <CitationList citations={CITATIONS} />
                        <details className="group rounded-xl border border-border">
                          <summary className="cursor-pointer select-none px-3 py-2 text-xs font-medium text-muted-foreground hover:text-foreground">
                            Inspect retrieved passages
                          </summary>
                          <div className="border-t border-border p-2">
                            <SourceList sources={RETRIEVED} floor={0.5} />
                          </div>
                        </details>
                      </>
                    )}
                    {turn.kind === "approval" && (
                      <ApprovalRequest
                        title="Issue $48.20 refund to card •• 4242"
                        requester="Copilot"
                        description="Refund for order #8182 — within the 30-day window"
                        detail="Sends the refund immediately. This cannot be undone from the copilot."
                        status={approval}
                        onAllow={() => setApproval("approved")}
                        onDeny={() => setApproval("denied")}
                      />
                    )}
                    <ResponseRating
                      rating={ratings[turn.id]?.rating}
                      submitted={ratings[turn.id]?.submitted}
                      onRate={(r) => rate(turn.id, r)}
                      onSubmitDetail={() => submitDetail(turn.id)}
                      reasons={["Wrong policy", "Outdated terms", "Missed the question", "Too cautious"]}
                    />
                  </>
                )}
              </div>
            ),
          )}
        </div>

        <div className="border-t border-border p-3">
          <ContextPicker
            className="mb-2"
            items={contextItems}
            selectedIds={contextSel}
            onSelectionChange={setContextSel}
            onRequestAccess={(item) => setGranted((g) => [...g, item.id])}
            triggerLabel="Add ticket context"
          />
          <PromptInput
            onSubmit={submit}
            placeholder="Paste the customer's message…"
            loading={streaming}
          />
        </div>
      </div>
    </Card>
  );
}
```
