Wait Time Handoff
A collapsible settings card for what an agent does when the customer goes quiet — a wait duration, a Team / Workflow handoff target, the handoff message, and a working-hours option.
AI-Patterns waits 2 hours, then hands off to Request a reference screenshot.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, ChevronUp, Timer, Users, Workflow } from "lucide-react";
import { cn } from "@/lib/utils";
export type WaitUnit = "seconds" | "minutes" | "hours";
export type HandoffKind = "team" | "workflow";
export interface HandoffOption {
id: string;
label: string;
/** Default message sent to the requester when this target is chosen. */
message?: string;
}
export interface WaitTimeHandoffValue {
amount: number;
unit: WaitUnit;
kind: HandoffKind;
targetId: string;
message: string;
workingHoursOnly: boolean;
}
export interface WaitTimeHandoffProps {
/** Name of the agent that is waiting, used in copy ("Set how long AI-Patterns waits…"). */
agentName?: string;
teams?: HandoffOption[];
workflows?: HandoffOption[];
defaultValue?: Partial<WaitTimeHandoffValue>;
defaultOpen?: boolean;
onChange?: (value: WaitTimeHandoffValue) => void;
className?: string;
}
const UNITS: { value: WaitUnit; label: string; max: number }[] = [
{ value: "seconds", label: "Second(s)", max: 59 },
{ value: "minutes", label: "Minute(s)", max: 59 },
{ value: "hours", label: "Hour(s)", max: 72 },
];
export const DEFAULT_TEAMS: HandoffOption[] = [
{
id: "maintainers",
label: "Maintainers",
message: "I'm passing this pattern request to the maintainers — they'll pick it up from here.",
},
{
id: "design-review",
label: "Design review",
message: "I've sent this to design review so someone can check the pattern against the registry.",
},
{
id: "docs",
label: "Docs",
message: "I've handed this to the docs team to finish the pattern spec.",
},
];
export const DEFAULT_WORKFLOWS: HandoffOption[] = [
{
id: "request-screenshot",
label: "Request a reference screenshot",
message: "We'll need a screenshot of where this pattern ships before we can add it to the registry.",
},
{
id: "open-issue",
label: "Open a pattern-request issue",
message: "I've saved this as a pattern-request issue in carlosmarch/ai-patterns so it isn't lost.",
},
{
id: "close-stale",
label: "Close as stale",
message: "No problem — reply here whenever you're ready and I'll pick the pattern request back up.",
},
];
function formatWait(amount: number, unit: WaitUnit) {
const singular = unit.slice(0, -1);
return `${amount} ${amount === 1 ? singular : unit}`;
}
function Select({
value,
onChange,
options,
label,
className,
}: {
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
label: string;
className?: string;
}) {
return (
<div className={cn("relative inline-flex", className)}>
<select
aria-label={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 w-full cursor-pointer appearance-none rounded-full border bg-background py-0 pl-3 pr-8 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<ChevronDown aria-hidden className="pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
</div>
);
}
export function WaitTimeHandoff({
agentName = "AI-Patterns",
teams = DEFAULT_TEAMS,
workflows = DEFAULT_WORKFLOWS,
defaultValue,
defaultOpen = true,
onChange,
className,
}: WaitTimeHandoffProps) {
const id = React.useId();
const [open, setOpen] = React.useState(defaultOpen);
const [value, setValue] = React.useState<WaitTimeHandoffValue>(() => {
const kind = defaultValue?.kind ?? "workflow";
const list = kind === "team" ? teams : workflows;
const target = list.find((o) => o.id === defaultValue?.targetId) ?? list[0];
return {
amount: defaultValue?.amount ?? 1,
unit: defaultValue?.unit ?? "minutes",
kind,
targetId: target?.id ?? "",
message: defaultValue?.message ?? target?.message ?? "",
workingHoursOnly: defaultValue?.workingHoursOnly ?? false,
};
});
// Track whether the user has edited the message, so switching target only
// replaces the default copy, never something they wrote.
const [messageEdited, setMessageEdited] = React.useState(Boolean(defaultValue?.message));
const [draftAmount, setDraftAmount] = React.useState(String(value.amount));
const options = value.kind === "team" ? teams : workflows;
const target = options.find((o) => o.id === value.targetId);
const unitMax = UNITS.find((u) => u.value === value.unit)!.max;
function update(patch: Partial<WaitTimeHandoffValue>) {
setValue((prev) => {
const next = { ...prev, ...patch };
onChange?.(next);
return next;
});
}
function selectTarget(kind: HandoffKind, targetId?: string) {
const list = kind === "team" ? teams : workflows;
const next = list.find((o) => o.id === targetId) ?? list[0];
update({
kind,
targetId: next?.id ?? "",
...(messageEdited ? {} : { message: next?.message ?? "" }),
});
}
function commitAmount(raw: string) {
const n = Math.round(Number(raw));
const clamped = Number.isFinite(n) ? Math.min(Math.max(n, 1), unitMax) : value.amount;
setDraftAmount(String(clamped));
if (clamped !== value.amount) update({ amount: clamped });
}
function changeUnit(unit: WaitUnit) {
const max = UNITS.find((u) => u.value === unit)!.max;
const amount = Math.min(value.amount, max);
setDraftAmount(String(amount));
update({ unit, amount });
}
return (
<section
aria-labelledby={`${id}-title`}
className={cn("w-full max-w-md overflow-hidden rounded-2xl border bg-card shadow-sm", className)}
>
<h3 className="m-0">
<button
type="button"
id={`${id}-title`}
aria-expanded={open}
aria-controls={`${id}-panel`}
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold outline-none focus-visible:bg-accent"
>
<Timer aria-hidden className="size-4" />
<span className="flex-1">Wait time</span>
<AnimatePresence mode="wait" initial={false}>
{!open && (
<motion.span
key="summary"
initial={{ opacity: 0, x: 4 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 4 }}
transition={{ duration: 0.15 }}
className="truncate text-xs font-normal text-muted-foreground"
>
{formatWait(value.amount, value.unit)} → {target?.label}
</motion.span>
)}
</AnimatePresence>
<motion.span animate={{ rotate: open ? 0 : 180 }} transition={{ duration: 0.2 }} className="text-muted-foreground">
<ChevronUp aria-hidden className="size-4" />
</motion.span>
</button>
</h3>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={`${id}-panel`}
role="region"
aria-labelledby={`${id}-title`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="flex flex-col gap-4 px-4 pb-4">
{/* Wait duration */}
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-xs text-muted-foreground">
Set how long {agentName} waits for a response.
</legend>
<div className="flex items-center gap-2">
<input
type="number"
inputMode="numeric"
aria-label="Wait duration"
min={1}
max={unitMax}
value={draftAmount}
onChange={(e) => setDraftAmount(e.target.value)}
onBlur={(e) => commitAmount(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && commitAmount(e.currentTarget.value)}
className="h-8 w-20 rounded-full border bg-background px-3 text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<Select
label="Wait unit"
value={value.unit}
onChange={(v) => changeUnit(v as WaitUnit)}
options={UNITS.map((u) => ({ value: u.value, label: u.label }))}
/>
</div>
</fieldset>
{/* Handoff target */}
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-xs text-muted-foreground">If there's no response, hand off to:</legend>
<div role="radiogroup" aria-label="Hand off to" className="grid grid-cols-2 gap-1 rounded-full bg-muted p-1">
{(
[
{ kind: "team", label: "Team", icon: Users },
{ kind: "workflow", label: "Workflow", icon: Workflow },
] as const
).map(({ kind, label, icon: Icon }) => {
const selected = value.kind === kind;
return (
<button
key={kind}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => !selected && selectTarget(kind)}
onKeyDown={(e) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(e.key)) {
e.preventDefault();
const other = kind === "team" ? "workflow" : "team";
selectTarget(other);
const sibling = (e.currentTarget.parentElement?.querySelector(
`[data-kind="${other}"]`,
) as HTMLButtonElement | null);
sibling?.focus();
}
}}
data-kind={kind}
className={cn(
"relative flex h-7 items-center justify-center gap-1.5 rounded-full text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
selected ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{selected && (
<motion.span
layoutId={`${id}-segment`}
className="absolute inset-0 rounded-full border bg-background shadow-sm"
transition={{ type: "spring", stiffness: 500, damping: 38 }}
/>
)}
<Icon aria-hidden className="relative size-3.5" />
<span className="relative">{label}</span>
</button>
);
})}
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={value.kind}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.14 }}
>
<Select
label={value.kind === "team" ? "Team" : "Workflow"}
value={value.targetId}
onChange={(v) => selectTarget(value.kind, v)}
options={options.map((o) => ({ value: o.id, label: o.label }))}
className="mt-1"
/>
</motion.div>
</AnimatePresence>
</fieldset>
{/* Handoff message */}
<div className="flex flex-col gap-1.5">
<label htmlFor={`${id}-message`} className="sr-only">
Message sent when handing off
</label>
<textarea
id={`${id}-message`}
rows={3}
value={value.message}
placeholder="Message the requester sees when the conversation is handed off…"
onChange={(e) => {
setMessageEdited(true);
update({ message: e.target.value });
}}
className="w-full resize-none rounded-xl border bg-background px-3 py-2.5 text-xs leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
{/* Working hours */}
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={value.workingHoursOnly}
onChange={(e) => update({ workingHoursOnly: e.target.checked })}
aria-describedby={`${id}-hours-hint`}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-foreground"
/>
<span className="flex flex-col gap-0.5">
<span className="text-xs">Start wait time during working hours only</span>
<span id={`${id}-hours-hint`} className="text-[11px] text-muted-foreground">
Requesters will be notified when maintainers are offline.
</span>
</span>
</label>
</div>
{/* Live summary */}
<p
aria-live="polite"
className="border-t bg-muted/40 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground"
>
{agentName} waits <span className="font-medium text-foreground">{formatWait(value.amount, value.unit)}</span>
{value.workingHoursOnly ? " of working hours" : ""}, then hands off to{" "}
<span className="font-medium text-foreground">
{value.kind === "team" ? `the ${target?.label} team` : target?.label}
</span>
.
</p>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Wait Time Handoff
## Summary
A collapsible settings card that decides what an AI agent does when the person it is talking to stops replying. The builder sets a wait duration, chooses whether an unanswered conversation goes to a **Team** or a **Workflow**, picks the specific target, writes the message the requester sees at handoff, and optionally limits the timer to working hours. A one-line summary restates the rule in plain language so the configuration can be checked at a glance.
## When to use
- Configuring an agent that asks someone a question — a missing detail, a screenshot, a confirmation — and must not leave the conversation open forever if they go quiet.
- When there is more than one sensible fallback — route to people (a team like Maintainers) or to automation (a follow-up, issue, or close-out workflow).
- As one section inside a larger agent or procedure settings page, where it can collapse to its summary.
## When not to use
- Timeouts the end user controls (a session expiry, an upload timeout) — this is an operator-facing configuration, not a runtime prompt.
- When the only outcome is "close the conversation" — a single duration field is enough; the Team/Workflow switch adds noise.
- Complex inactivity logic with multiple reminders or escalation tiers — use a workflow or procedure builder where the steps are explicit.
## Anatomy
- Header: timer icon, "Wait time" title, and a chevron that collapses the card. Collapsed, it shows a summary ("2 hours → Request a reference screenshot").
- Duration row: a compact numeric input and a unit select (seconds, minutes, hours), under the helper "Set how long [agent] waits for a response."
- Handoff label: "If there's no response, hand off to:".
- Segmented control with two options, Team and Workflow, each with an icon; the selected segment is a raised pill.
- Target select: the list changes with the segment — teams or saved workflows.
- Handoff message: a textarea prefilled with the target's default copy.
- Working-hours checkbox with a helper line explaining what requesters see outside hours.
- Live summary footer: "AI-Patterns waits **2 hours**, then hands off to **Request a reference screenshot**."
## Behavior
- The duration is clamped on blur or Enter to a sane range per unit (1–59 seconds or minutes, 1–72 hours); switching unit re-clamps the current value instead of rejecting it.
- Switching between Team and Workflow slides the selection pill across (spring, ~200 ms) and swaps the target select with a short fade, selecting the first option of the new list.
- Choosing a target replaces the message with that target's default copy — but only until the builder edits the message. Once edited, their text is never overwritten by further target changes.
- With "working hours only" on, the timer pauses outside working hours and the summary reads "2 hours of working hours".
- The chevron rotates and the body collapses with a height + opacity transition; the collapsed header keeps the summary visible so the section is never a black box.
- Every change emits the full value object (`amount`, `unit`, `kind`, `targetId`, `message`, `workingHoursOnly`) so the parent can save or validate it.
## Content guidelines
- Name the agent in the helper ("Set how long AI-Patterns waits…") so it is clear whose patience is being configured.
- Phrase the handoff label as the condition, not the action: "If there's no response, hand off to:".
- Default messages should tell the requester what happens next, in one sentence, without blaming them for going quiet: "We'll need a screenshot of where this pattern ships before we can add it to the registry."
- Name workflows by outcome ("Request a reference screenshot", "Open a pattern-request issue"), and teams by who they are ("Maintainers"), so the summary reads as a sentence.
- Keep the summary literal — duration, condition, destination — no marketing copy.
## Accessibility
- The header is a heading containing a disclosure `button` with `aria-expanded` and `aria-controls` pointing at the body region.
- Duration and handoff groups are `fieldset`s whose `legend` is the visible helper text; the number input and unit select have their own accessible names.
- The Team/Workflow switch is a `radiogroup` of `radio` buttons with roving `tabIndex`; Arrow keys move and select, matching native radio behavior. Icons are `aria-hidden`.
- Selects are native `<select>` elements styled as pills, so they keep platform keyboard and screen-reader behavior.
- The checkbox's helper text is linked with `aria-describedby`. The summary footer is `aria-live="polite"` so changes are announced without stealing focus.
- Motion is limited to short opacity / position transitions; honor `prefers-reduced-motion` by disabling the pill slide and height animation.
## Related patterns
- **Procedure Builder** — Use it as well to author the workflow this card hands the conversation off to.
- **Agent Triggers** — Use it as well to decide when the agent starts, while Wait Time Handoff decides when it gives up.
- **Human in the Loop Question** — Use it as well when the agent is waiting on an answer to a specific question, so the customer knows what to reply to.
Do’s and don’ts
Do use it for
- Configuring an agent that asks someone a question — a missing detail, a screenshot, a confirmation — and must not leave the conversation open forever if they go quiet.
- When there is more than one sensible fallback — route to people (a team like Maintainers) or to automation (a follow-up, issue, or close-out workflow).
- As one section inside a larger agent or procedure settings page, where it can collapse to its summary.
Don’t use it for
- Timeouts the end user controls (a session expiry, an upload timeout) — this is an operator-facing configuration, not a runtime prompt.
- When the only outcome is "close the conversation" — a single duration field is enough; the Team/Workflow switch adds noise.
- Complex inactivity logic with multiple reminders or escalation tiers — use a workflow or procedure builder where the steps are explicit.
Related patterns
Procedure Builder
Use it as well to author the workflow this card hands the conversation off to.
Agent Triggers
Use it as well to decide when the agent starts, while Wait Time Handoff decides when it gives up.
Human in the Loop Question
Use it as well when the agent is waiting on an answer to a specific question, so the customer knows what to reply to.