Skip to content

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

Full screen

Dana Okafor

Nora is holding the chat

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.json

Usage

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

PropTypeDefaultDescription
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 / onDraftChangestring / (draft: string) => void—Controls the composer text. Picking a drafted reply fills it for editing before sending.
queueOpen / defaultQueueOpen / onQueueOpenChangeboolean / boolean / (open: boolean) => voidtrueThe queue pane from 56rem wide; toggle with its header button or Cmd/Ctrl + backslash. Narrower, it opens as a drawer.
detailsOpen / defaultDetailsOpen / onDetailsOpenChangeboolean / boolean / (open: boolean) => voidtrueThe customer details pane from 72rem wide; a drawer below that.
classNamestring—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.