Skip to content
Star on GitHub
Review and approveHand-off to a person
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Hand-off to a person

The agent hands a conversation to a person, with what they need to pick it up. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add human-handoff
Handed off to a personRefund over $500 needs a person
Assigned to
Finding someoneBilling team
Reply due in 14 min15 min target
Sent to Dana
Thanks, Dana. A refund this size needs someone on our billing team, so I've passed your conversation to them. They'll reply here within 15 minutes.
Context passed along

Dana was charged twice for her annual plan on Oct 21 and wants one charge back. The agent confirmed the duplicate, but can't refund more than $500.

Amount$588.00
CustomerDana Lee · Pro since 2023
Order#48213 · charged twice
Handing off to BillingThe agent has stopped replying to Dana
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add human-handoff

React

$npx designme add human-handoff --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Hand-off to a person shows an agent passing a conversation to a human. It says why, like "Refund over $500 needs a person", who it is assigned to with their team and whether they are free, and when the reply is due, counting down. Beside that is the context they need to pick it up: a short summary, a few key facts and a link to the whole conversation. Under the assignee is what the customer was told, with Edit, and a row at the foot says where it stands. If the person can't take it, Reassign opens a small menu of who else can.

  • When an agent reaches something it isn't allowed to do, like a large refund, and a person has to take over.
  • When the person picking it up should not have to read the whole conversation to know what is going on.
  • When there is a target time to reply and people need to see how much of it is left.
  • When whoever was assigned is away or busy, and someone needs to pass it on quickly.
03

Usage

Import it and drive it from your own state.

Import

import { HumanHandoff } from '@/components/designme/human-handoff/HumanHandoff';

Example

The demo above as a React component, 65 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { HumanHandoff, type HandoffPerson, type HandoffStatus } from './HumanHandoff';
import { useStory, useTakeover, useWait } from '../lib/useScript';

/* ---------- Hand-off to a person: Maya is assigned but is out, so the person reassigns it to Alex, who takes it ---------- */
// someone free takes the conversation in a few seconds, someone in a call takes longer, and someone who is out never does
type Member = HandoffPerson & { wait?: number };
const TEAM: Member[] = [
  { name: 'Alex Rivera', initials: 'AR', color: 'var(--dm-av-m)', team: 'Billing', presence: 'Available', tone: 'ok', wait: 2500 },
  { name: 'Sam Ortiz', initials: 'SO', color: 'var(--dm-av-p)', team: 'Billing', presence: 'In a call', wait: 6000 },
  { name: 'Maya Chen', initials: 'MC', color: 'var(--dm-av-l)', team: 'Billing', presence: 'Out until 2pm', tone: 'warn' }
];
const first = (p: HandoffPerson) => p.name.split(' ')[0];
const MESSAGE = "Thanks, Dana. A refund this size needs someone on our billing team, so I've passed your conversation to them. They'll reply here within 15 minutes.";

export default function HumanHandoffDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const wait = useWait();
  const run = useRef(0);
  const [who, setWho] = useState<HandoffPerson | null>(null);
  const [status, setStatus] = useState<HandoffStatus>({ title: 'Handing off to Billing', detail: 'The agent has stopped replying to Dana' });
  const [left, setLeft] = useState(846);
  const [message, setMessage] = useState(MESSAGE);
  const [edited, setEdited] = useState(false);

  // the reply is due 15 minutes after the hand-off; this one starts with 14:06 left
  useEffect(() => { const id = setInterval(() => setLeft(s => s - 1), 1000); return () => clearInterval(id); }, []);

  const assign = async (p: Member) => {
    const id = ++run.current;
    // their status comes back a moment after they are assigned
    setWho({ ...p, presence: undefined, tone: undefined });
    setStatus({ title: `Waiting for ${first(p)} to accept`, detail: 'The agent has stopped replying to Dana' });
    await wait(1600); if (id !== run.current) return;
    setWho(p);
    if (!p.wait) { setStatus({ tone: 'warn', title: `${first(p)} is out until 2pm`, detail: 'The reply is due before then. Reassign it to someone who is free.' }); return; }
    await wait(p.wait); if (id !== run.current) return;
    setStatus({ tone: 'ok', person: p, title: `${first(p)} joined`, detail: 'Reading the conversation' });
    await wait(1800); if (id !== run.current) return;
    setStatus({ tone: 'ok', person: p, title: `With ${first(p)} · agent paused`, detail: `The agent won't reply while ${first(p)} has it` });
  };
  // the agent finds someone on the team a moment after it hands off, unless you have picked someone first
  const mine = useTakeover(ref);
  useEffect(() => { const t = setTimeout(() => { if (!mine()) void assign(TEAM[2]); }, 1500); return () => { clearTimeout(t); run.current++; }; }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // the preview's story, on the same beats as the site's
  useStory(ref, async ({ wait, press }) => {
    await wait(5600); press('.dm-hox-rb');
    await wait(2500); press('.dm-hox-pop .dm-mi:nth-of-type(1)');
  });

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <HumanHandoff reason="Refund over $500 needs a person" assignee={who} finding={{ title: 'Finding someone', detail: 'Billing team' }}
        people={TEAM} onReassign={p => void assign(p as Member)} secondsLeft={left}
        summary="Dana was charged twice for her annual plan on Oct 21 and wants one charge back. The agent confirmed the duplicate, but can't refund more than $500."
        facts={[{ label: 'Amount', value: '$588.00' }, { label: 'Customer', value: 'Dana Lee · Pro since 2023' }, { label: 'Order', value: '#48213 · charged twice' }]}
        link={{ label: 'View conversation · 12 messages', tip: 'Opens the whole conversation' }}
        message={message} messageLabel="Sent to Dana" edited={edited} onMessageChange={t => { setMessage(t); setEdited(true); }} status={status} />
    </div>
  );
}
04

Props

Everything HumanHandoff takes.

PropTypeWhat it does
titlestring
reasonrequiredstringWhy the agent handed off, like "Refund over $500 needs a person"
assigneerequiredHandoffPerson | nullWho has it, or null while the agent is still finding someone
finding{ title: string; detail?: string }The two lines shown while assignee is null
peopleHandoffPerson[]The people it can go to instead. Leave it out to hide Reassign
onReassign(person: HandoffPerson) => void
secondsLeftrequirednumberSeconds left until the reply is due. It shows whole minutes and turns amber under five; at 0 or less it reads overdue
targetSecondsnumberThe whole window, for the bar and the line beside it ("15 min target")
summaryrequiredstringWhat the person needs to pick it up
factsHandoffFact[]A few key facts, as label and value rows
link{ label: string; href?: string; onClick?: () => void; tip?: string }A link to the whole conversation
messagerequiredstringWhat the agent told the customer
messageLabelstringThe label over the message, like "Sent to Dana"
onMessageChange(text: string) => voidAdds Edit: the message turns editable, and Save calls this with the new text
editedbooleanAdds "· edited" to the label
statusrequiredHandoffStatusThe row at the foot that says where it stands
labels{ assignedTo?: string; context?: string; reassign?: string; reassignTo?: string; target?: string; edit?: string; save?: string; cancel?: string; edited?: string }
classNamestring
05

Code

The full source, as the CLI installs it.

HumanHandoff.tsxReact · 177 lines
'use client';
import { useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import { Avatar, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './human-handoff.css';

export interface HandoffPerson {
  /** Used as the key, so keep it unique */
  name: string;
  initials?: string;
  /** The avatar's colour, like 'var(--dm-av-m)' */
  color?: string;
  /** Like "Billing" */
  team?: string;
  /** Whether they can take it now, like "Available", "In a call" or "Out until 2pm" */
  presence?: string;
  /** ok puts a green dot before the presence, warn turns it amber */
  tone?: 'ok' | 'warn';
}

export interface HandoffFact {
  label: string;
  value: string;
}

export interface HandoffStatus {
  /** wait is neutral with a spinner, warn amber with an alert, ok green */
  tone?: 'wait' | 'warn' | 'ok';
  title: string;
  detail?: string;
  /** Shown in place of the icon, like the person who just joined */
  person?: HandoffPerson;
}

export interface HumanHandoffProps {
  title?: string;
  /** Why the agent handed off, like "Refund over $500 needs a person" */
  reason: string;
  /** Who has it, or null while the agent is still finding someone */
  assignee: HandoffPerson | null;
  /** The two lines shown while assignee is null */
  finding?: { title: string; detail?: string };
  /** The people it can go to instead. Leave it out to hide Reassign */
  people?: HandoffPerson[];
  onReassign?: (person: HandoffPerson) => void;
  /** Seconds left until the reply is due. It shows whole minutes and turns amber under five; at 0 or less it reads overdue */
  secondsLeft: number;
  /** The whole window, for the bar and the line beside it ("15 min target") */
  targetSeconds?: number;
  /** What the person needs to pick it up */
  summary: string;
  /** A few key facts, as label and value rows */
  facts?: HandoffFact[];
  /** A link to the whole conversation */
  link?: { label: string; href?: string; onClick?: () => void; tip?: string };
  /** What the agent told the customer */
  message: string;
  /** The label over the message, like "Sent to Dana" */
  messageLabel?: string;
  /** Adds Edit: the message turns editable, and Save calls this with the new text */
  onMessageChange?: (text: string) => void;
  /** Adds "· edited" to the label */
  edited?: boolean;
  /** The row at the foot that says where it stands */
  status: HandoffStatus;
  labels?: { assignedTo?: string; context?: string; reassign?: string; reassignTo?: string; target?: string; edit?: string; save?: string; cancel?: string; edited?: string };
  className?: string;
}

const initialsOf = (p: HandoffPerson) => p.initials ?? p.name.split(' ').map(w => w[0]).join('').slice(0, 2);
const due = (s: number) => { const m = Math.floor(s / 60); return s <= 0 ? 'Reply overdue' : m < 1 ? 'Reply due in under a minute' : `Reply due in ${m} min`; };

/** The agent hands a conversation to a person: why, who has it (with Reassign), when the reply is due, the context they
    need to pick it up, and what the customer was told, with a row at the foot that says where it stands. */
export function HumanHandoff({
  title = 'Handed off to a person', reason, assignee, finding = { title: 'Finding someone' }, people, onReassign, secondsLeft, targetSeconds = 900,
  summary, facts = [], link, message, messageLabel = 'Sent to the customer', onMessageChange, edited, status, labels = {}, className
}: HumanHandoffProps) {
  const L = { assignedTo: 'Assigned to', context: 'Context passed along', reassign: 'Reassign', reassignTo: 'Reassign to', target: `${Math.round(targetSeconds / 60)} min target`,
    edit: 'Edit', save: 'Save', cancel: 'Cancel', edited: 'edited', ...labels };
  const menu = usePopover({ align: 'end', side: 'down' });
  const [editing, setEditing] = useState(false);
  // the message box is drawn afresh after each edit, so what React renders and what was typed never disagree
  const [rev, setRev] = useState(0);
  const box = useRef<HTMLDivElement>(null);
  const left = Math.max(0, secondsLeft);
  const tone = secondsLeft <= 0 ? 'dm-bad' : secondsLeft < 300 ? 'dm-warn' : '';

  const startEdit = () => {
    setEditing(true);
    requestAnimationFrame(() => { const b = box.current; if (!b) return; b.focus(); const s = getSelection(); s?.selectAllChildren(b); s?.collapseToEnd(); });
  };
  const save = () => { const t = box.current?.innerText.trim() ?? ''; if (t && t !== message) onMessageChange?.(t); setEditing(false); setRev(r => r + 1); };
  const cancel = () => { setEditing(false); setRev(r => r + 1); };
  const st = status.tone ?? 'wait';
  const link0 = link && (link.href
    ? <a className="dm-fchip dm-lnk dm-hox-lnk" href={link.href} onClick={link.onClick}><Icon name="link" size="xs" /><span>{link.label}</span></a>
    : <button type="button" className="dm-fchip dm-lnk dm-hox-lnk" onClick={link.onClick}><Icon name="link" size="xs" /><span>{link.label}</span></button>);

  return (
    <div data-dm="" className={className} style={{ width: '100%', containerType: 'inline-size' }}>
      <div className="dm-hox dm-panel">
        <div className="dm-hox-h">
          <span className="dm-hox-ic"><Icon name="user" /></span>
          <div className="dm-hox-ht"><b>{title}</b><span className="dm-muted">{reason}</span></div>
        </div>
        <div className="dm-hox-b">
          <div className="dm-hox-col">
            <span className="dm-hox-lb">{L.assignedTo}</span>
            <div className="dm-hox-who">
              <span className="dm-hox-av">{assignee ? <Avatar key={assignee.name} className="dm-enter" initials={initialsOf(assignee)} color={assignee.color ?? 'var(--dm-av-l)'} /> : <Spin />}</span>
              <span className="dm-hox-wt">
                <b><Swap>{assignee ? assignee.name : finding.title}</Swap></b>
                {assignee ? (
                  <span key={assignee.name + (assignee.presence ?? '')} className={cx('dm-hox-wd dm-enter', assignee.tone && 'dm-' + assignee.tone)}>
                    {assignee.presence && <span className="dm-dot" />}<span>{[assignee.team, assignee.presence].filter(Boolean).join(' · ')}</span>
                  </span>
                ) : <span className="dm-hox-wd"><span>{finding.detail}</span></span>}
              </span>
              {people && (
                <button type="button" className="dm-hox-rb" disabled={!assignee} {...menu.trigger}>{L.reassign}<Icon name="chev-d" size="xs" /></button>
              )}
            </div>
            <div className={cx('dm-hox-sla', tone)}>
              <div className="dm-hox-sl"><Icon name="clock" /><span className="dm-hox-due dm-num">{due(secondsLeft)}</span><span className="dm-muted">{L.target}</span></div>
              <div className="dm-hox-bar" role="progressbar" aria-label="Time left to reply" aria-valuemin={0} aria-valuemax={targetSeconds} aria-valuenow={left}>
                <i style={{ width: `${Math.min(100, (left / targetSeconds) * 100)}%` }} />
              </div>
            </div>
            <div className="dm-hox-ms">
              <div className="dm-hox-mh">
                <span className="dm-hox-lb">{edited ? `${messageLabel} · ${L.edited}` : messageLabel}</span><span className="dm-sp" />
                {onMessageChange && editing && <button type="button" className="dm-pill dm-ghost dm-sm" onClick={cancel}>{L.cancel}</button>}
                {onMessageChange && (editing
                  ? <button type="button" className="dm-pill dm-primary dm-sm" onClick={save}>{L.save}</button>
                  : <button type="button" className="dm-pill dm-ghost dm-sm" onClick={startEdit}><Icon name="edit" size="xs" />{L.edit}</button>)}
              </div>
              <div key={rev} ref={box} className="dm-hox-mb" contentEditable={editing} suppressContentEditableWarning role="textbox" aria-multiline="true" aria-readonly={!editing}
                aria-label={messageLabel} spellCheck={false}>{message}</div>
            </div>
          </div>
          <div className="dm-hox-col">
            <span className="dm-hox-lb">{L.context}</span>
            <p className="dm-hox-sum">{summary}</p>
            {facts.length > 0 && (
              <div className="dm-hox-facts">
                {facts.map(f => <div key={f.label} className="dm-hox-f"><span className="dm-k">{f.label}</span><span className="dm-v dm-num">{f.value}</span></div>)}
              </div>
            )}
            {link0 && (link?.tip ? <Tip label={link.tip}>{link0}</Tip> : link0)}
          </div>
        </div>
        <div className={cx('dm-hox-st', st !== 'wait' && 'dm-' + st)} aria-live="polite">
          <span className="dm-g">{status.person ? <Avatar initials={initialsOf(status.person)} color={status.person.color ?? 'var(--dm-av-l)'} /> : st === 'wait' ? <Spin /> : <Icon name={st === 'warn' ? 'alert' : 'ok'} />}</span>
          <span className="dm-t"><b><Swap>{status.title}</Swap></b>{status.detail && <span><Swap>{status.detail}</Swap></span>}</span>
        </div>
        {people && (
          <div {...menu.pop} className={cx(menu.pop.className, 'dm-hox-pop')} role="menu" aria-label={L.reassignTo}>
            <div className="dm-ph">{L.reassignTo}</div>
            {people.map(p => (
              <button type="button" key={p.name} className="dm-mi" role="menuitemradio" aria-checked={p.name === assignee?.name}
                onClick={() => { if (p.name !== assignee?.name) onReassign?.(p); menu.closeSoon(); }}>
                <Avatar initials={initialsOf(p)} color={p.color ?? 'var(--dm-av-l)'} />
                <span>{p.name}<span className="dm-d">{[p.team, p.presence].filter(Boolean).join(' · ')}</span></span>
                <span className="dm-ck"><Icon name="check" size="xs" /></span>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}
human-handoff.cssReact · 73 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Hand-off to a person ---------- */
/* the agent passes a conversation to a person: why, who has it (with Reassign), when the reply is due, what they need to
   pick it up, and what the customer was told. The type and spacing are Draft review's; the two columns stack when narrow */
.dm-hox{position:relative;width:100%;margin:0 auto;padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-hox-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.dm-hox-ic{width:32px;height:32px;border-radius:9px;background:var(--dm-chip);color:var(--dm-fg2);display:grid;place-items:center;flex:none}
.dm-hox-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-hox-ht b{font-weight:500;font-size:14px}
.dm-hox-ht span{font-size:13px}
.dm-hox-b{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--dm-s-4) var(--dm-s-6);align-items:start;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
@container (max-width:600px){.dm-hox-b{grid-template-columns:minmax(0,1fr)}.dm-hox-col+.dm-hox-col{padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}}
.dm-hox-col{display:flex;flex-direction:column;gap:var(--dm-s-2);min-width:0}
.dm-hox-lb{font-size:12px;color:var(--dm-muted)}
.dm-hox-who{display:flex;align-items:center;gap:10px;min-height:40px}
.dm-hox-av{width:28px;height:28px;display:grid;place-items:center;flex:none;border-radius:50%;background:var(--dm-chip);color:var(--dm-fg2)}
.dm-hox-av .dm-av{width:28px;height:28px;font-size:11px}
.dm-hox-wt{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-hox-wt b{font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-hox-wd{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-hox-wd .dm-dot{color:var(--dm-faint)}
.dm-hox-wd.dm-ok .dm-dot{color:var(--dm-ok)}
.dm-hox-wd.dm-warn,.dm-hox-wd.dm-warn .dm-dot{color:var(--dm-warn)}
/* Reassign: a small dropdown, like the one for how long in Permission gate */
.dm-hox-rb{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--dm-s-2) 0 10px;border-radius:var(--dm-r-pill);font-size:13px;color:var(--dm-fg2);flex:none;transition:background .16s,color .16s}
.dm-hox-rb[aria-expanded="true"]{background:var(--dm-row);color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-hox-rb:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-hox-rb>svg{color:var(--dm-muted);transition:transform .32s var(--dm-ease)}
.dm-hox-rb[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-hox-rb[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dm-dis)}
.dm-hox-pop{min-width:240px}
.dm-hox-pop .dm-mi .dm-av{width:20px;height:20px;font-size:9px}
.dm-hox-pop .dm-mi>span:not(.dm-ck):not(.dm-av){flex:1;min-width:0}
/* when the reply is due: the line counts down and the bar empties, amber in the last five minutes */
.dm-hox-sla{display:flex;flex-direction:column;gap:var(--dm-s-2);padding-top:var(--dm-s-2)}
.dm-hox-sl{display:flex;align-items:center;gap:var(--dm-s-2);font-size:13px}
.dm-hox-sl>svg{color:var(--dm-fg2);flex:none}
.dm-hox-sl .dm-muted{margin-left:auto;font-size:12px;white-space:nowrap}
.dm-hox-due{min-width:0}
.dm-hox-bar{height:4px;border-radius:2px;background:var(--dm-chip);overflow:hidden}
.dm-hox-bar i{display:block;height:100%;width:100%;border-radius:inherit;background:var(--dm-fg2);transition:width 1s linear,background .3s}
.dm-hox-sla.dm-warn .dm-hox-sl>svg,.dm-hox-sla.dm-warn .dm-hox-due{color:var(--dm-warn)}
.dm-hox-sla.dm-warn .dm-hox-bar i{background:var(--dm-warn)}
.dm-hox-sla.dm-bad .dm-hox-sl>svg,.dm-hox-sla.dm-bad .dm-hox-due{color:var(--dm-bad)}
.dm-hox-sum{margin:0;font-size:13px;line-height:1.5;color:var(--dm-fg2)}
.dm-hox-facts{display:flex;flex-direction:column;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-hox-f{display:flex;align-items:center;gap:10px;min-height:32px;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13px}
.dm-hox-f .dm-k{width:72px;flex:none;color:var(--dm-muted)}
.dm-hox-f .dm-v{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dm-hox-lnk{align-self:flex-start;max-width:100%}
.dm-hox-lnk>span{overflow:hidden;text-overflow:ellipsis}
/* what the customer was told, editable in place */
.dm-hox-ms{display:flex;flex-direction:column;gap:var(--dm-s-2);margin-top:var(--dm-s-1);padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-hox-mh{display:flex;align-items:center;gap:var(--dm-s-2);min-height:28px}
.dm-hox-mh .dm-sp{flex:1}
.dm-hox-mb{padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:13.5px;line-height:1.5;outline:0;transition:box-shadow var(--dm-t-fast),background var(--dm-t-fast)}
.dm-hox-mb[contenteditable="true"]{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent)}
@supports (corner-shape:squircle){.dm-hox-mb[contenteditable="true"]{corner-shape:squircle;border-radius:var(--dm-r-box-sq)}}
.dm-hox-mb[contenteditable="true"]:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
/* where it stands, in one row at the foot: neutral while it waits, amber when the person can't take it, green once they have */
.dm-hox-st{display:flex;align-items:center;gap:var(--dm-s-3);padding:10px 10px 10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-chip);font-size:13.5px;transition:background .32s}
.dm-hox-st.dm-warn{background:var(--dm-warn-bg)}
.dm-hox-st.dm-ok{background:var(--dm-ok-bg)}
.dm-hox-st .dm-g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--dm-fg2)}
.dm-hox-st.dm-warn .dm-g{color:var(--dm-warn)}
.dm-hox-st.dm-ok .dm-g{color:var(--dm-ok)}
.dm-hox-st .dm-g .dm-av{width:18px;height:18px;font-size:8.5px}
.dm-hox-st .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-hox-st .dm-t b{font-weight:400}
/* the second line sits on the row's tint, so it is fg2 rather than muted */
.dm-hox-st .dm-t>span{font-size:13px;color:var(--dm-fg2)}
.dm-hox-st .dm-t,.dm-hox-sum,.dm-hox-mb,.dm-tmx-note>span{text-wrap:pretty}
human-handoff.htmlHTML · 33 lines
<div class="card wide" data-c="human-handoff">
  <div class="stage-in">
    <div class="hox panel">
      <div class="hox-h"><span class="hox-ic"><svg class="i s"><use href="#i-user"/></svg></span><div class="hox-ht"><b>Handed off to a person</b><span class="muted">Refund over $500 needs a person</span></div></div>
      <div class="hox-b">
        <div class="hox-col">
          <span class="hox-lb">Assigned to</span>
          <div class="hox-who"><span class="hox-av"><span class="spin"></span></span><span class="hox-wt"><b class="hox-wn">Finding someone</b><span class="hox-wd"><span class="hox-wv">Billing team</span></span></span><button class="hox-rb" aria-haspopup="menu" aria-expanded="false" data-pop=".hox-pop" data-pop-align="end" data-pop-pos="down" disabled>Reassign<svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
          <div class="hox-sla"><div class="hox-sl"><svg class="i s"><use href="#i-clock"/></svg><span class="hox-due num">Reply due in 14 min</span><span class="muted">15 min target</span></div><div class="hox-bar" role="progressbar" aria-label="Time left to reply" aria-valuemin="0" aria-valuemax="900"><i></i></div></div>
          <div class="hox-ms">
            <div class="hox-mh"><span class="hox-lb hox-ml">Sent to Dana</span><span class="sp"></span><button class="pill ghost sm hox-no" hidden>Cancel</button><button class="pill ghost sm hox-ed"><svg class="i xs"><use href="#i-edit"/></svg>Edit</button></div>
            <div class="hox-mb" role="textbox" aria-multiline="true" aria-readonly="true" aria-label="Message sent to Dana" spellcheck="false">Thanks, Dana. A refund this size needs someone on our billing team, so I've passed your conversation to them. They'll reply here within 15 minutes.</div>
          </div>
        </div>
        <div class="hox-col">
          <span class="hox-lb">Context passed along</span>
          <p class="hox-sum">Dana was charged twice for her annual plan on Oct 21 and wants one charge back. The agent confirmed the duplicate, but can't refund more than $500.</p>
          <div class="hox-facts">
            <div class="hox-f"><span class="k">Amount</span><span class="v num">$588.00</span></div>
            <div class="hox-f"><span class="k">Customer</span><span class="v">Dana Lee · Pro since 2023</span></div>
            <div class="hox-f"><span class="k">Order</span><span class="v num">#48213 · charged twice</span></div>
          </div>
          <button class="fchip lnk hox-lnk" data-tip="Opens the whole conversation"><svg class="i xs"><use href="#i-link"/></svg><span>View conversation · 12 messages</span></button>
        </div>
      </div>
      <div class="hox-st" aria-live="polite"><span class="g"><span class="spin"></span></span><span class="t"><b class="hox-sa">Handing off to Billing</b><span class="hox-sb">The agent has stopped replying to Dana</span></span></div>
      <div class="pop hox-pop" role="menu" aria-label="Reassign to">
        <div class="ph">Reassign to</div>
      </div>
    </div>
  </div>
</div>
human-handoff.cssHTML · 72 lines
/* ---------- Hand-off to a person ---------- */
/* the agent passes a conversation to a person: why, who has it (with Reassign), when the reply is due, what they need to
   pick it up, and what the customer was told. The type and spacing are Draft review's; the two columns stack when narrow */
.hox{position:relative;width:100%;margin:0 auto;padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-3)}
.hox-h{display:flex;align-items:center;gap:var(--s-3)}
.hox-ic{width:32px;height:32px;border-radius:9px;background:var(--chip);color:var(--fg2);display:grid;place-items:center;flex:none}
.hox-ht{flex:1;min-width:0;display:flex;flex-direction:column}
.hox-ht b{font-weight:500;font-size:14px}
.hox-ht span{font-size:13px}
.hox-b{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:var(--s-4) var(--s-6);align-items:start;padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
@container (max-width:600px){.hox-b{grid-template-columns:minmax(0,1fr)}.hox-col+.hox-col{padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}}
.hox-col{display:flex;flex-direction:column;gap:var(--s-2);min-width:0}
.hox-lb{font-size:12px;color:var(--muted)}
.hox-who{display:flex;align-items:center;gap:10px;min-height:40px}
.hox-av{width:28px;height:28px;display:grid;place-items:center;flex:none;border-radius:50%;background:var(--chip);color:var(--fg2)}
.hox-av .av{width:28px;height:28px;font-size:11px}
.hox-wt{flex:1;min-width:0;display:flex;flex-direction:column}
.hox-wt b{font-weight:500;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hox-wd{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hox-wd .dot{color:var(--faint)}
.hox-wd.ok .dot{color:var(--ok)}
.hox-wd.warn,.hox-wd.warn .dot{color:var(--warn)}
/* Reassign: a small dropdown, like the one for how long in Permission gate */
.hox-rb{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 var(--s-2) 0 10px;border-radius:var(--r-pill);font-size:13px;color:var(--fg2);flex:none;transition:background .16s,color .16s}
.hox-rb[aria-expanded="true"]{background:var(--row);color:var(--fg)}
@media (hover:hover) and (pointer:fine){.hox-rb:hover{background:var(--row);color:var(--fg)}}
.hox-rb>svg{color:var(--muted);transition:transform .32s var(--ease)}
.hox-rb[aria-expanded="true"]>svg{transform:rotate(180deg)}
.hox-rb[disabled]{opacity:1;cursor:not-allowed;background:none;color:var(--dis)}
.hox-pop{min-width:240px}
.hox-pop .mi .av{width:20px;height:20px;font-size:9px}
.hox-pop .mi>span:not(.ck):not(.av){flex:1;min-width:0}
/* when the reply is due: the line counts down and the bar empties, amber in the last five minutes */
.hox-sla{display:flex;flex-direction:column;gap:var(--s-2);padding-top:var(--s-2)}
.hox-sl{display:flex;align-items:center;gap:var(--s-2);font-size:13px}
.hox-sl>svg{color:var(--fg2);flex:none}
.hox-sl .muted{margin-left:auto;font-size:12px;white-space:nowrap}
.hox-due{min-width:0}
.hox-bar{height:4px;border-radius:2px;background:var(--chip);overflow:hidden}
.hox-bar i{display:block;height:100%;width:100%;border-radius:inherit;background:var(--fg2);transition:width 1s linear,background .3s}
.hox-sla.warn .hox-sl>svg,.hox-sla.warn .hox-due{color:var(--warn)}
.hox-sla.warn .hox-bar i{background:var(--warn)}
.hox-sla.bad .hox-sl>svg,.hox-sla.bad .hox-due{color:var(--bad)}
.hox-sum{margin:0;font-size:13px;line-height:1.5;color:var(--fg2)}
.hox-facts{display:flex;flex-direction:column;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.hox-f{display:flex;align-items:center;gap:10px;min-height:32px;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);font-size:13px}
.hox-f .k{width:72px;flex:none;color:var(--muted)}
.hox-f .v{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hox-lnk{align-self:flex-start;max-width:100%}
.hox-lnk>span{overflow:hidden;text-overflow:ellipsis}
/* what the customer was told, editable in place */
.hox-ms{display:flex;flex-direction:column;gap:var(--s-2);margin-top:var(--s-1);padding-top:var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.hox-mh{display:flex;align-items:center;gap:var(--s-2);min-height:28px}
.hox-mh .sp{flex:1}
.hox-mb{padding:10px var(--s-3);border-radius:var(--r-in);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:13.5px;line-height:1.5;outline:0;transition:box-shadow var(--t-fast),background var(--t-fast)}
.hox-mb[contenteditable="true"]{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent)}
@supports (corner-shape:squircle){.hox-mb[contenteditable="true"]{corner-shape:squircle;border-radius:var(--r-box-sq)}}
.hox-mb[contenteditable="true"]:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
/* where it stands, in one row at the foot: neutral while it waits, amber when the person can't take it, green once they have */
.hox-st{display:flex;align-items:center;gap:var(--s-3);padding:10px 10px 10px var(--s-3);border-radius:var(--r-in);background:var(--chip);font-size:13.5px;transition:background .32s}
.hox-st.warn{background:var(--warn-bg)}
.hox-st.ok{background:var(--ok-bg)}
.hox-st .g{width:18px;height:18px;display:grid;place-items:center;flex:none;color:var(--fg2)}
.hox-st.warn .g{color:var(--warn)}
.hox-st.ok .g{color:var(--ok)}
.hox-st .g .av{width:18px;height:18px;font-size:8.5px}
.hox-st .t{flex:1;min-width:0;display:flex;flex-direction:column}
.hox-st .t b{font-weight:400}
/* the second line sits on the row's tint, so it is fg2 rather than muted */
.hox-st .t>span{font-size:13px;color:var(--fg2)}
.hox-st .t,.hox-sum,.hox-mb,.tmx-note>span{text-wrap:pretty}
human-handoff.jsHTML · 73 lines
DM.def('human-handoff', (r, c) => {
  const IC = { user:I('user'), chev:I('chev-d','xs'), clock:I('clock'), link:I('link','xs'), edit:I('edit','xs') };
  r.innerHTML = r.innerHTML.replace(/\{(\w+)\}/g, (m, k) => IC[k] || m);
  const root = $('.hox', r), rb = $('.hox-rb', r), av = $('.hox-av', r), wn = $('.hox-wn', r), wd = $('.hox-wd', r), pop = $('.hox-pop', r);
  const sla = $('.hox-sla', r), due = $('.hox-due', r), bar = $('.hox-bar', r), fill = $('.hox-bar i', r);
  const row = $('.hox-st', r), sa = $('.hox-sa', r), sb = $('.hox-sb', r), mb = $('.hox-mb', r), ed = $('.hox-ed', r), no = $('.hox-no', r), ml = $('.hox-ml', r);
  // the billing team: someone free takes the conversation in a few seconds, someone in a call takes longer, and someone
  // who is out never does
  const P = [
    { k:'alex', n:'Alex Rivera', i:'AR', bg:'var(--av-m)', on:'Available', tone:'ok', wait:2500 },
    { k:'sam', n:'Sam Ortiz', i:'SO', bg:'var(--av-p)', on:'In a call', tone:'', wait:6000 },
    { k:'maya', n:'Maya Chen', i:'MC', bg:'var(--av-l)', on:'Out until 2pm', tone:'warn' }
  ];
  const first = p => p.n.split(' ')[0];
  pop.insertAdjacentHTML('beforeend', P.map(p => `<button class="mi" role="menuitemradio" aria-checked="false" data-close data-k="${p.k}">${AV(p.i, p.bg)}<span>${p.n}<span class="d">Billing · ${p.on}</span></span><span class="ck">${I('check','xs')}</span></button>`).join(''));
  // when the reply is due: 15 minutes from the hand-off, counting down by the second; amber under five minutes
  const TARGET = 900;
  let left = 846;
  const showDue = () => {
    const m = Math.floor(left / 60);
    due.textContent = left <= 0 ? 'Reply overdue' : m < 1 ? 'Reply due in under a minute' : `Reply due in ${m} min`;
    sla.classList.toggle('warn', left > 0 && left < 300); sla.classList.toggle('bad', left <= 0);
    fill.style.width = Math.max(0, left / TARGET * 100) + '%'; bar.setAttribute('aria-valuenow', Math.max(0, left));
  };
  showDue(); c.every(1000, () => { left--; showDue(); });
  // the row at the foot says where it stands
  const state = (tone, glyph, a, b) => { row.className = 'hox-st' + (tone ? ' ' + tone : ''); $('.g', row).innerHTML = glyph; swap(sa, a); swap(sb, b); };
  let who = null, run = 0, phase = 'finding';
  function assign(p){
    const id = ++run; who = p; phase = 'waiting';
    av.innerHTML = AV(p.i, p.bg); enter(av.firstElementChild);
    swap(wn, p.n); wd.className = 'hox-wd'; wd.innerHTML = '<span>Billing</span>';
    $$('.mi', pop).forEach(m => m.setAttribute('aria-checked', m.dataset.k === p.k)); rb.disabled = false;
    state('', '<span class="spin"></span>', `Waiting for ${first(p)} to accept`, 'The agent has stopped replying to Dana');
    // their status comes back a moment later
    c.later(1600, () => {
      if (id !== run) return;
      wd.className = 'hox-wd' + (p.tone ? ' ' + p.tone : ''); wd.innerHTML = `<span class="dot"></span><span>Billing · ${p.on}</span>`; enter(wd);
      if (!p.wait){ phase = 'away'; state('warn', I('alert'), `${first(p)} is out until 2pm`, 'The reply is due before then. Reassign it to someone who is free.'); return; }
      c.later(p.wait, () => {
        if (id !== run) return;
        phase = 'joined'; state('ok', AV(p.i, p.bg), `${first(p)} joined`, 'Reading the conversation');
        c.later(1800, () => { if (id !== run) return; phase = 'with'; state('ok', AV(p.i, p.bg), `With ${first(p)} · agent paused`, `The agent won't reply while ${first(p)} has it`); });
      });
    });
  }
  $$('.mi', pop).forEach(m => m.addEventListener('click', () => { const p = P.find(x => x.k === m.dataset.k); if (p !== who) assign(p); }));
  // the preview hands it to Maya once on its own, unless you have picked someone first
  c.auto(1500, () => assign(P[2]));
  // what the customer was told can be corrected in place
  let said = mb.textContent, editing = false;
  const edit = on => {
    editing = on; mb.contentEditable = on ? 'true' : 'false'; mb.setAttribute('aria-readonly', !on); no.hidden = !on;
    ed.className = 'pill sm hox-ed ' + (on ? 'primary' : 'ghost'); ed.innerHTML = on ? 'Save' : I('edit','xs') + 'Edit';
    if (on){ mb.focus(); const s = getSelection(); s.selectAllChildren(mb); s.collapseToEnd(); }
  };
  ed.onclick = () => {
    if (!editing) return edit(true);
    const t = mb.textContent.trim();
    if (t && t !== said){ said = t; swap(ml, 'Sent to Dana · edited'); } else mb.textContent = said;
    edit(false);
  };
  no.onclick = () => { mb.textContent = said; edit(false); };
  // the preview's story: Maya is assigned but is out, so the person reassigns it to Alex, who takes it
  c.story(async s => {
    await s.until(() => phase === 'away'); await s.beat(4);
    await s.tap(rb); await s.beat(2);
    await s.point($('.mi[data-k="sam"]', pop), false); await s.beat(1);
    await s.tap($('.mi[data-k="alex"]', pop)); s.away();
    await s.until(() => phase === 'with'); await s.beat(6);
  });
});
06

Questions

What people ask about it.

Does it assign the conversation or notify anyone?
No. It shows the assignee and status you pass. Your code routes the conversation, checks who is free, and calls back when they accept; onReassign tells you who was picked.
Does the timer count down by itself?
No. Pass secondsLeft and update it, for example once a second, from the time the reply is due. The line and the bar follow it.
Does editing the message send the change to the customer?
No. Save calls onMessageChange with the new text. Update the message where the customer sees it, then pass it back with edited.
07

More in Review and approve

Moments where a person checks, steers or signs off on the agent's work.

Change reviewAccept or reject each change the agent made, file by file.
Plan reviewCheck the agent's plan, edit steps, then let it run.
Permission gateThe agent asks before it touches something new.
Decision promptThe agent asks a few quick questions before it starts.
Proposal cardA suggested change with confidence, alternatives and undo.
Design tunerTweak a component by hand or let the agent tune it.
Rewrite toolbarSelect a sentence and let the agent rewrite it in place.
Launch checklistThe agent checks a design before it ships, and fixes what fails.
All 14 in Review and approve →