Review and approveHand-off to a person
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callHand-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.
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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add human-handoffReact
npx designme add human-handoff --reactThe 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.
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.
reasonsits under the title.assigneeshows an avatar, name,teamandpresence, with a green dot fortone: 'ok'or amber for'warn'. Whileassigneeis null a spinner andfindingfill the row.peopleadds Reassign, a small menu of who else can take it with their team and presence. Picking someone callsonReassign.secondsLeftreads "Reply due in 14 min" and empties a bar overtargetSeconds(15 minutes by default). Under five minutes it turns amber, and at zero it reads overdue.summary,facts(label and value rows) andlinkare the context passed along.messageis what the customer was told.onMessageChangeadds Edit: the message turns editable, then Save or Cancel, andeditedadds "· edited" to its label.statusis the row at the foot: neutral with a spinner while it waits, amber with an alert when the person can't take it, and green with the person's avatar once they have, like "With Alex · agent paused".- On wide screens the assignment and the context sit side by side; below 600px they stack.
- Reassign says whether its menu is open. Each person is a menu item that marks the current one with
aria-checked, and Escape closes the menu. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- The time left is a progress bar labelled "Time left to reply", with the seconds left as its value.
- The message is a textbox that is read-only until Edit is pressed, and the status row is a polite live region, so changes like "Alex joined" are announced.
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>
);
}
Props
Everything HumanHandoff takes.
| Prop | Type | What it does |
|---|---|---|
title | string | |
reasonrequired | string | Why the agent handed off, like "Refund over $500 needs a person" |
assigneerequired | HandoffPerson | null | Who has it, or null while the agent is still finding someone |
finding | { title: string; detail?: string } | The two lines shown while assignee is null |
people | HandoffPerson[] | The people it can go to instead. Leave it out to hide Reassign |
onReassign | (person: HandoffPerson) => void | |
secondsLeftrequired | number | Seconds left until the reply is due. It shows whole minutes and turns amber under five; at 0 or less it reads overdue |
targetSeconds | number | The whole window, for the bar and the line beside it ("15 min target") |
summaryrequired | string | What the person needs to pick it up |
facts | HandoffFact[] | A few key facts, as label and value rows |
link | { label: string; href?: string; onClick?: () => void; tip?: string } | A link to the whole conversation |
messagerequired | string | What the agent told the customer |
messageLabel | string | The label over the message, like "Sent to Dana" |
onMessageChange | (text: string) => void | Adds Edit: the message turns editable, and Save calls this with the new text |
edited | boolean | Adds "· edited" to the label |
statusrequired | HandoffStatus | The 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 } | |
className | string |
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);
});
});
Questions
What people ask about it.
- Does it assign the conversation or notify anyone?
- No. It shows the
assigneeandstatusyou pass. Your code routes the conversation, checks who is free, and calls back when they accept;onReassigntells you who was picked. - Does the timer count down by itself?
- No. Pass
secondsLeftand 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
onMessageChangewith the new text. Update the message where the customer sees it, then pass it back withedited.
More in Review and approve
Moments where a person checks, steers or signs off on the agent's work.