Screens
Handoff Inbox
Where people take over from the bot — escalated conversations by urgency, what the bot already did, reply and hand back.
2 variantsMIT
Preview
Dana Okafor
Nora is holding the chat
November 3
Dana Okafor
Hi, I'm moving my number 07700 900123 from Vireo to you. It's been two days and it still says pending.
Nora
The port was rejected by Vireo: the account PIN didn't match. I've resubmitted it with the PIN you gave me.
Dana Okafor
It says rejected again. I need this number for work tomorrow!
Nora
I'm sorry, it was rejected a second time. I'm bringing in a teammate who can sort this out with Vireo. Your temporary number keeps working meanwhile.
Handoff
Bot handed off · Port rejected twice
Wants: Move 07700 900123 from Vireo before work tomorrow.
- Checked the port: rejected, PIN mismatch
- Resubmitted with the customer's PIN: rejected again
- Kept the temporary number active
telco.port_status: Done
telco.port_submit: Failed
Nora told Dana a person is on the way.
Installation
Copies the screen into components/ and installs every component it uses, with their dependencies and theme tokens.
pnpm dlx shadcn@latest add https://tuffui.com/r/handoff-inbox.jsonUsage
Pass your own data and wire the callbacks to your agent runtime.
import { HandoffInbox } from "@/components/handoff-inbox";
export function SupportInbox({ inbox, chat }: { inbox: Inbox; chat: Chat }) {
return (
<div className="h-dvh">
<HandoffInbox
data={{
team: inbox.teamName,
operator: { id: inbox.me.id, name: inbox.me.name },
bot: { name: "Nora" },
queue: inbox.escalations, // customer, snippet, reason, waiting minutes…
activeId: chat.id,
messages: chat.turns.map((t) => ({
id: t.id,
from: t.handoff ? "handoff" : t.author, // "customer" | "bot" | "operator"
at: t.createdAt,
content: t.text,
})),
summary: chat.botSummary, // { wants, tried, calls }
suggestions: chat.draftedReplies,
customer: chat.customer, // plan, orders + orderColumns, notes
}}
status={chat.takenBy ? "active" : "waiting"}
onTakeOver={() => chat.takeOver()}
onSend={(text) => chat.reply(text)}
onHandBack={() => chat.handBack()}
onSelect={(escalation) => inbox.open(escalation.id)}
/>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data* | HandoffInboxData | — | The queue, the open conversation, the bot's summary and drafted replies, and the customer's details. |
| status | "waiting" | "active" | "returned" | "waiting" | Waiting: the bot holds the chat and Take over is offered. Active: the operator replies from the composer. Returned: handed back to the bot. |
| onTakeOver | () => void | — | The operator joins the chat. |
| onHandBack | () => void | — | The bot takes the chat back. |
| onSend | (text: string) => void | — | A reply from the operator. |
| onSelect | (escalation: Escalation) => void | — | A conversation picked in the queue (click, or arrow keys and Enter). |
| draft / onDraftChange | string / (draft: string) => void | — | Controls the composer text. Picking a drafted reply fills it for editing before sending. |
| queueOpen / defaultQueueOpen / onQueueOpenChange | boolean / boolean / (open: boolean) => void | true | The queue pane from 56rem wide; toggle with its header button or Cmd/Ctrl + backslash. Narrower, it opens as a drawer. |
| detailsOpen / defaultDetailsOpen / onDetailsOpenChange | boolean / boolean / (open: boolean) => void | true | The customer details pane from 72rem wide; a drawer below that. |
| className | string | — | Extra classes for the root. |
type Escalation = {
id: string;
customer: { name: string; avatar?: string };
snippet: string; // last thing the customer said
reason: string; // why the bot handed off
tone?: StatusTone;
urgent?: boolean;
waiting: number; // minutes waiting for a person
assignee?: string; // operator id; drives the "Mine" filter
};
type HandoffMessage = {
id: string;
from: "customer" | "bot" | "operator" | "handoff";
at: string; // ISO
content: React.ReactNode;
};
type HandoffInboxData = {
team: string;
operator: { id: string; name: string; avatar?: string };
bot: { name: string };
queue: Escalation[];
activeId?: string;
messages: HandoffMessage[];
summary: { wants: string; tried: string[]; calls?: ToolCall[] };
suggestions?: PromptSuggestion[];
customer: {
name: string; avatar?: string;
plan: string; planTone?: StatusTone; since: string;
orders: DataRow[]; orderColumns: DataColumn[];
notes: Memory[];
};
};
// The screen fills its parent and lays out by its own width (container queries).
// Give the parent a height.