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.
support-copilot.tsx283 lines8 componentsReact + Tailwind, no dependencies
npx shadcn@latest add https://scrimui.dev/r/support-copilot.jsonAgent promptClaude Code · Cursor · any agent
Add the Customer Support Copilot pattern from the Scrim UI registry to this project — a complete screen, not a single component.
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.
## 1. Install
```bash
npx shadcn@latest add https://scrimui.dev/r/support-copilot.json
```
This writes the screen to `components/blocks/support-copilot.tsx` and pulls in the components it is built from, each landing at `components/ui/`. Everything is plain React + Tailwind with no runtime dependencies. The imports in the block already point at those paths, so it compiles as installed.
## 2. What it is made of
- `conversation-sidebar` — Conversation Sidebar
- `context-picker` — Context Picker
- `citation-ui` — Citation UI
- `source-list` — Source List
- `confidence-answer` — Confidence Answer
- `inline-correction` — Inline Correction
- `approval-request` — Approval Request
- `response-rating` — Response Rating
Each is a separate file you can edit or replace on its own; the block is the arrangement, not a monolith.
## 3. Rules this layout depends on
Keep these when adapting the screen — they are the reasons it works, and they are easy to break while restyling:
- Ground every draft: citations under the answer, retrieved passages with scores one disclosure away.
- Say low confidence out loud — name the exact thing to check, not a vague percentage.
- Let the agent correct a wrong fact inline; the correction feeds the copilot and the original stays as the receipt.
- Gate money moves: the copilot drafts the refund, a human approves it, and both outcomes leave a visible receipt.
- Put a rating row under every draft — thumbs with reason chips are how the team finds the topics the copilot handles badly.
## 4. Do not do these
- A confident-looking draft with no sources — in support, an ungrounded answer is a refund dispute waiting to happen.
- Silently falling back to a guess when retrieval is thin instead of surfacing a low-confidence answer.
- Corrections typed into a feedback void — if fixing a fact doesn't change the next draft, agents stop correcting.
- Auto-executing refunds because 'the policy check passed' — policy eligibility is not authorization.
- Thumbs-down with no reason chips: a bare downvote can't tell a wrong-policy problem from a tone problem.
The demo content in the file — messages, file names, model names — is placeholder. Replace it with this project's real data and wire the handlers to real state rather than shipping the stubs.
Reference: https://scrimui.dev/patterns/support-copilotInstalls the screen and every component it is built from, and carries the layout rules from this page so an agent does not restyle them away.
Live Preview
Ticket #1042 — Refund request
Customer: Ana R. · Pro plan since 2024 · Order #8182 · “I was charged but already cancelled”
Paste the customer's message to get a grounded draft
Answers cite the Help Center and refund policy — the copilot says when it's guessing.
- Help Center
- refund-policy-2026.pdf
- ≈ 3.8k tokens
Built from these components
The chat-history sidebar every AI app rebuilds — search, inline rename, pin, and delete with undo, grouped by date, with loading and empty states.
The @-mention menu for adding context to a prompt — files, web pages and knowledge bases with search, recent items, access states and token cost.
Inline citation markers in generated text — numbered footnotes that reveal the source in a hover preview card.
The passages retrieval actually returned, with similarity scores and a visible relevance floor — including the case where nothing cleared it.
An answer that says how sure it is — a warning badge only when there is something to warn about, and a hedge that names what to verify.
Fix the answer where it is wrong, in place — the edit becomes training data, so the component keeps the original alongside it rather than overwriting it.
The human-in-the-loop confirmation — what the agent wants to do, the exact details, and allow or deny before it acts.
Thumbs up and down that ask the one follow-up question worth asking — with a state for feedback that has been sent and cannot be taken back.
Pattern code
This file composes the components above. Copy each component from its page, then this pattern file wires them together.
"use client";
import * as React from "react";
import { ConversationSidebar, type ConversationGroup } from "../../conversation-sidebar/conversation-sidebar";
import { ContextPicker, type ContextItem } from "../../context-picker/context-picker";
import { CitationList, type Citation } from "../../citation-ui/citation-ui";
import { SourceList, type RetrievedSource } from "../../source-list/source-list";
import { ConfidenceAnswer } from "../../confidence-answer/confidence-answer";
import { InlineCorrection } from "../../inline-correction/inline-correction";
import { ApprovalRequest, type ApprovalState } from "../../approval-request/approval-request";
import { ResponseRating, type Rating } from "../../response-rating/response-rating";
import { StreamingMessage } from "../../streaming-message/streaming-message";
import { PromptInput } from "../../prompt-input/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 (
<div className="flex h-[640px] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
{/* Ticket rail */}
<aside className="hidden w-56 shrink-0 border-r border-zinc-200 dark:border-zinc-800 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-zinc-200 px-4 py-3 dark:border-zinc-800">
<p className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Ticket #1042 — Refund request</p>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
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-zinc-300 px-4 py-6 text-center dark:border-zinc-700">
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-200">Paste the customer's message to get a grounded draft</p>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
Answers cite the Help Center and refund policy — the copilot says when it's guessing.
</p>
</div>
)}
{turns.map((turn) =>
turn.role === "agent" ? (
<div key={turn.id} className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-zinc-900 px-4 py-2.5 text-sm leading-6 text-white dark:bg-zinc-100 dark:text-zinc-900">
{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-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
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-zinc-200 dark:border-zinc-800">
<summary className="cursor-pointer select-none px-3 py-2 text-xs font-medium text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100">
Inspect retrieved passages
</summary>
<div className="border-t border-zinc-200 p-2 dark:border-zinc-800">
<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-zinc-200 p-3 dark:border-zinc-800">
<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>
</div>
);
}
When to use it
- Ground every draft: citations under the answer, retrieved passages with scores one disclosure away.
- Say low confidence out loud — name the exact thing to check, not a vague percentage.
- Let the agent correct a wrong fact inline; the correction feeds the copilot and the original stays as the receipt.
- Gate money moves: the copilot drafts the refund, a human approves it, and both outcomes leave a visible receipt.
- Put a rating row under every draft — thumbs with reason chips are how the team finds the topics the copilot handles badly.
What breaks in production
- A confident-looking draft with no sources — in support, an ungrounded answer is a refund dispute waiting to happen.
- Silently falling back to a guess when retrieval is thin instead of surfacing a low-confidence answer.
- Corrections typed into a feedback void — if fixing a fact doesn't change the next draft, agents stop correcting.
- Auto-executing refunds because 'the policy check passed' — policy eligibility is not authorization.
- Thumbs-down with no reason chips: a bare downvote can't tell a wrong-policy problem from a tone problem.
More Patterns
The canonical chat interface — sidebar, streaming messages, prompt input with model selector, and sources.
AI Research AssistantA research flow that shows search tool calls, reasoning, sources and a cited final answer.
AI Coding AgentA coding run with agent status, tool calls, diffs and a human-in-the-loop approval gate.
AI Voice AssistantA voice-first conversation — live waveform states, a recording input, a spoken transcript and a typed fallback.
Model & Memory PreferencesA preferences screen that picks the model, reasoning level and tools, and manages persistent memory.
Artifact WorkspaceChat on the left, generated output on the right — artifacts open from the answer, stream, version, and fail without breaking the conversation.
Document Q&A WorkspaceAsk your own documents — upload and parse, cited answers with inspectable passages, an honest not-found state, and a visible context budget.
Structured Extraction & ReviewUpload a document, watch fields fill in, then review the flagged ones — per-field confidence, corrections that keep the original, export earned.
Image Generation StudioA one-screen generation studio — prompt composer and model picker beside a feed of queued, staged, blocked and ready image results with variants.
Multi-agent Ops ConsoleWatch a fleet of agents at once — parallel statuses, an inspectable handoff, a waiting approval, a failed child run, and per-run plus fleet cost.
Generative UI DashboardThe model assembles a dashboard from a controlled widget registry — streamed props, an unsupported-request fallback, and widget clicks that re-enter the chat.