{"slug":"wait-time-handoff","category":"agents","job":"oversee","added":"2026-11-04T16:20Z","title":"Wait Time Handoff","description":"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.","url":"https://ai-patterns.app/patterns/agents/wait-time-handoff","markdown":"https://ai-patterns.app/patterns/agents/wait-time-handoff.md","json":"https://ai-patterns.app/api/patterns/agents/wait-time-handoff","seenIn":[{"team":"Intercom","source":"https://www.intercom.com/help/en/articles/7155449-customer-or-teammate-has-been-unresponsive"}],"spec":"# Wait Time Handoff\n\n## Summary\nA 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.\n\n## When to use\n- 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.\n- 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).\n- As one section inside a larger agent or procedure settings page, where it can collapse to its summary.\n\n## When not to use\n- Timeouts the end user controls (a session expiry, an upload timeout) — this is an operator-facing configuration, not a runtime prompt.\n- When the only outcome is \"close the conversation\" — a single duration field is enough; the Team/Workflow switch adds noise.\n- Complex inactivity logic with multiple reminders or escalation tiers — use a workflow or procedure builder where the steps are explicit.\n\n## Anatomy\n- Header: timer icon, \"Wait time\" title, and a chevron that collapses the card. Collapsed, it shows a summary (\"2 hours → Request a reference screenshot\").\n- Duration row: a compact numeric input and a unit select (seconds, minutes, hours), under the helper \"Set how long [agent] waits for a response.\"\n- Handoff label: \"If there's no response, hand off to:\".\n- Segmented control with two options, Team and Workflow, each with an icon; the selected segment is a raised pill.\n- Target select: the list changes with the segment — teams or saved workflows.\n- Handoff message: a textarea prefilled with the target's default copy.\n- Working-hours checkbox with a helper line explaining what requesters see outside hours.\n- Live summary footer: \"AI-Patterns waits **2 hours**, then hands off to **Request a reference screenshot**.\"\n\n## Behavior\n- 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.\n- 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.\n- 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.\n- With \"working hours only\" on, the timer pauses outside working hours and the summary reads \"2 hours of working hours\".\n- 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.\n- Every change emits the full value object (`amount`, `unit`, `kind`, `targetId`, `message`, `workingHoursOnly`) so the parent can save or validate it.\n\n## Content guidelines\n- Name the agent in the helper (\"Set how long AI-Patterns waits…\") so it is clear whose patience is being configured.\n- Phrase the handoff label as the condition, not the action: \"If there's no response, hand off to:\".\n- 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.\"\n- 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.\n- Keep the summary literal — duration, condition, destination — no marketing copy.\n\n## Accessibility\n- The header is a heading containing a disclosure `button` with `aria-expanded` and `aria-controls` pointing at the body region.\n- 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.\n- 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`.\n- Selects are native `<select>` elements styled as pills, so they keep platform keyboard and screen-reader behavior.\n- The checkbox's helper text is linked with `aria-describedby`. The summary footer is `aria-live=\"polite\"` so changes are announced without stealing focus.\n- Motion is limited to short opacity / position transitions; honor `prefers-reduced-motion` by disabling the pill slide and height animation.\n\n## Related patterns\n- **Procedure Builder** — Use it as well to author the workflow this card hands the conversation off to.\n- **Agent Triggers** — Use it as well to decide when the agent starts, while Wait Time Handoff decides when it gives up.\n- **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.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronDown, ChevronUp, Timer, Users, Workflow } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type WaitUnit = \"seconds\" | \"minutes\" | \"hours\";\nexport type HandoffKind = \"team\" | \"workflow\";\n\nexport interface HandoffOption {\n  id: string;\n  label: string;\n  /** Default message sent to the requester when this target is chosen. */\n  message?: string;\n}\n\nexport interface WaitTimeHandoffValue {\n  amount: number;\n  unit: WaitUnit;\n  kind: HandoffKind;\n  targetId: string;\n  message: string;\n  workingHoursOnly: boolean;\n}\n\nexport interface WaitTimeHandoffProps {\n  /** Name of the agent that is waiting, used in copy (\"Set how long AI-Patterns waits…\"). */\n  agentName?: string;\n  teams?: HandoffOption[];\n  workflows?: HandoffOption[];\n  defaultValue?: Partial<WaitTimeHandoffValue>;\n  defaultOpen?: boolean;\n  onChange?: (value: WaitTimeHandoffValue) => void;\n  className?: string;\n}\n\nconst UNITS: { value: WaitUnit; label: string; max: number }[] = [\n  { value: \"seconds\", label: \"Second(s)\", max: 59 },\n  { value: \"minutes\", label: \"Minute(s)\", max: 59 },\n  { value: \"hours\", label: \"Hour(s)\", max: 72 },\n];\n\nexport const DEFAULT_TEAMS: HandoffOption[] = [\n  {\n    id: \"maintainers\",\n    label: \"Maintainers\",\n    message: \"I'm passing this pattern request to the maintainers — they'll pick it up from here.\",\n  },\n  {\n    id: \"design-review\",\n    label: \"Design review\",\n    message: \"I've sent this to design review so someone can check the pattern against the registry.\",\n  },\n  {\n    id: \"docs\",\n    label: \"Docs\",\n    message: \"I've handed this to the docs team to finish the pattern spec.\",\n  },\n];\n\nexport const DEFAULT_WORKFLOWS: HandoffOption[] = [\n  {\n    id: \"request-screenshot\",\n    label: \"Request a reference screenshot\",\n    message: \"We'll need a screenshot of where this pattern ships before we can add it to the registry.\",\n  },\n  {\n    id: \"open-issue\",\n    label: \"Open a pattern-request issue\",\n    message: \"I've saved this as a pattern-request issue in carlosmarch/ai-patterns so it isn't lost.\",\n  },\n  {\n    id: \"close-stale\",\n    label: \"Close as stale\",\n    message: \"No problem — reply here whenever you're ready and I'll pick the pattern request back up.\",\n  },\n];\n\nfunction formatWait(amount: number, unit: WaitUnit) {\n  const singular = unit.slice(0, -1);\n  return `${amount} ${amount === 1 ? singular : unit}`;\n}\n\nfunction Select({\n  value,\n  onChange,\n  options,\n  label,\n  className,\n}: {\n  value: string;\n  onChange: (v: string) => void;\n  options: { value: string; label: string }[];\n  label: string;\n  className?: string;\n}) {\n  return (\n    <div className={cn(\"relative inline-flex\", className)}>\n      <select\n        aria-label={label}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        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\"\n      >\n        {options.map((o) => (\n          <option key={o.value} value={o.value}>\n            {o.label}\n          </option>\n        ))}\n      </select>\n      <ChevronDown aria-hidden className=\"pointer-events-none absolute right-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n    </div>\n  );\n}\n\nexport function WaitTimeHandoff({\n  agentName = \"AI-Patterns\",\n  teams = DEFAULT_TEAMS,\n  workflows = DEFAULT_WORKFLOWS,\n  defaultValue,\n  defaultOpen = true,\n  onChange,\n  className,\n}: WaitTimeHandoffProps) {\n  const id = React.useId();\n  const [open, setOpen] = React.useState(defaultOpen);\n  const [value, setValue] = React.useState<WaitTimeHandoffValue>(() => {\n    const kind = defaultValue?.kind ?? \"workflow\";\n    const list = kind === \"team\" ? teams : workflows;\n    const target = list.find((o) => o.id === defaultValue?.targetId) ?? list[0];\n    return {\n      amount: defaultValue?.amount ?? 1,\n      unit: defaultValue?.unit ?? \"minutes\",\n      kind,\n      targetId: target?.id ?? \"\",\n      message: defaultValue?.message ?? target?.message ?? \"\",\n      workingHoursOnly: defaultValue?.workingHoursOnly ?? false,\n    };\n  });\n  // Track whether the user has edited the message, so switching target only\n  // replaces the default copy, never something they wrote.\n  const [messageEdited, setMessageEdited] = React.useState(Boolean(defaultValue?.message));\n  const [draftAmount, setDraftAmount] = React.useState(String(value.amount));\n\n  const options = value.kind === \"team\" ? teams : workflows;\n  const target = options.find((o) => o.id === value.targetId);\n  const unitMax = UNITS.find((u) => u.value === value.unit)!.max;\n\n  function update(patch: Partial<WaitTimeHandoffValue>) {\n    setValue((prev) => {\n      const next = { ...prev, ...patch };\n      onChange?.(next);\n      return next;\n    });\n  }\n\n  function selectTarget(kind: HandoffKind, targetId?: string) {\n    const list = kind === \"team\" ? teams : workflows;\n    const next = list.find((o) => o.id === targetId) ?? list[0];\n    update({\n      kind,\n      targetId: next?.id ?? \"\",\n      ...(messageEdited ? {} : { message: next?.message ?? \"\" }),\n    });\n  }\n\n  function commitAmount(raw: string) {\n    const n = Math.round(Number(raw));\n    const clamped = Number.isFinite(n) ? Math.min(Math.max(n, 1), unitMax) : value.amount;\n    setDraftAmount(String(clamped));\n    if (clamped !== value.amount) update({ amount: clamped });\n  }\n\n  function changeUnit(unit: WaitUnit) {\n    const max = UNITS.find((u) => u.value === unit)!.max;\n    const amount = Math.min(value.amount, max);\n    setDraftAmount(String(amount));\n    update({ unit, amount });\n  }\n\n  return (\n    <section\n      aria-labelledby={`${id}-title`}\n      className={cn(\"w-full max-w-md overflow-hidden rounded-2xl border bg-card shadow-sm\", className)}\n    >\n      <h3 className=\"m-0\">\n        <button\n          type=\"button\"\n          id={`${id}-title`}\n          aria-expanded={open}\n          aria-controls={`${id}-panel`}\n          onClick={() => setOpen((o) => !o)}\n          className=\"flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold outline-none focus-visible:bg-accent\"\n        >\n          <Timer aria-hidden className=\"size-4\" />\n          <span className=\"flex-1\">Wait time</span>\n          <AnimatePresence mode=\"wait\" initial={false}>\n            {!open && (\n              <motion.span\n                key=\"summary\"\n                initial={{ opacity: 0, x: 4 }}\n                animate={{ opacity: 1, x: 0 }}\n                exit={{ opacity: 0, x: 4 }}\n                transition={{ duration: 0.15 }}\n                className=\"truncate text-xs font-normal text-muted-foreground\"\n              >\n                {formatWait(value.amount, value.unit)} → {target?.label}\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <motion.span animate={{ rotate: open ? 0 : 180 }} transition={{ duration: 0.2 }} className=\"text-muted-foreground\">\n            <ChevronUp aria-hidden className=\"size-4\" />\n          </motion.span>\n        </button>\n      </h3>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.div\n            id={`${id}-panel`}\n            role=\"region\"\n            aria-labelledby={`${id}-title`}\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.22, ease: [0.25, 0.1, 0.25, 1] }}\n            className=\"overflow-hidden\"\n          >\n            <div className=\"flex flex-col gap-4 px-4 pb-4\">\n              {/* Wait duration */}\n              <fieldset className=\"flex flex-col gap-2\">\n                <legend className=\"mb-2 text-xs text-muted-foreground\">\n                  Set how long {agentName} waits for a response.\n                </legend>\n                <div className=\"flex items-center gap-2\">\n                  <input\n                    type=\"number\"\n                    inputMode=\"numeric\"\n                    aria-label=\"Wait duration\"\n                    min={1}\n                    max={unitMax}\n                    value={draftAmount}\n                    onChange={(e) => setDraftAmount(e.target.value)}\n                    onBlur={(e) => commitAmount(e.target.value)}\n                    onKeyDown={(e) => e.key === \"Enter\" && commitAmount(e.currentTarget.value)}\n                    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\"\n                  />\n                  <Select\n                    label=\"Wait unit\"\n                    value={value.unit}\n                    onChange={(v) => changeUnit(v as WaitUnit)}\n                    options={UNITS.map((u) => ({ value: u.value, label: u.label }))}\n                  />\n                </div>\n              </fieldset>\n\n              {/* Handoff target */}\n              <fieldset className=\"flex flex-col gap-2\">\n                <legend className=\"mb-2 text-xs text-muted-foreground\">If there&apos;s no response, hand off to:</legend>\n                <div role=\"radiogroup\" aria-label=\"Hand off to\" className=\"grid grid-cols-2 gap-1 rounded-full bg-muted p-1\">\n                  {(\n                    [\n                      { kind: \"team\", label: \"Team\", icon: Users },\n                      { kind: \"workflow\", label: \"Workflow\", icon: Workflow },\n                    ] as const\n                  ).map(({ kind, label, icon: Icon }) => {\n                    const selected = value.kind === kind;\n                    return (\n                      <button\n                        key={kind}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={selected}\n                        tabIndex={selected ? 0 : -1}\n                        onClick={() => !selected && selectTarget(kind)}\n                        onKeyDown={(e) => {\n                          if ([\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\"].includes(e.key)) {\n                            e.preventDefault();\n                            const other = kind === \"team\" ? \"workflow\" : \"team\";\n                            selectTarget(other);\n                            const sibling = (e.currentTarget.parentElement?.querySelector(\n                              `[data-kind=\"${other}\"]`,\n                            ) as HTMLButtonElement | null);\n                            sibling?.focus();\n                          }\n                        }}\n                        data-kind={kind}\n                        className={cn(\n                          \"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\",\n                          selected ? \"font-medium text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                        )}\n                      >\n                        {selected && (\n                          <motion.span\n                            layoutId={`${id}-segment`}\n                            className=\"absolute inset-0 rounded-full border bg-background shadow-sm\"\n                            transition={{ type: \"spring\", stiffness: 500, damping: 38 }}\n                          />\n                        )}\n                        <Icon aria-hidden className=\"relative size-3.5\" />\n                        <span className=\"relative\">{label}</span>\n                      </button>\n                    );\n                  })}\n                </div>\n\n                <AnimatePresence mode=\"wait\" initial={false}>\n                  <motion.div\n                    key={value.kind}\n                    initial={{ opacity: 0, y: 4 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={{ opacity: 0, y: -4 }}\n                    transition={{ duration: 0.14 }}\n                  >\n                    <Select\n                      label={value.kind === \"team\" ? \"Team\" : \"Workflow\"}\n                      value={value.targetId}\n                      onChange={(v) => selectTarget(value.kind, v)}\n                      options={options.map((o) => ({ value: o.id, label: o.label }))}\n                      className=\"mt-1\"\n                    />\n                  </motion.div>\n                </AnimatePresence>\n              </fieldset>\n\n              {/* Handoff message */}\n              <div className=\"flex flex-col gap-1.5\">\n                <label htmlFor={`${id}-message`} className=\"sr-only\">\n                  Message sent when handing off\n                </label>\n                <textarea\n                  id={`${id}-message`}\n                  rows={3}\n                  value={value.message}\n                  placeholder=\"Message the requester sees when the conversation is handed off…\"\n                  onChange={(e) => {\n                    setMessageEdited(true);\n                    update({ message: e.target.value });\n                  }}\n                  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\"\n                />\n              </div>\n\n              {/* Working hours */}\n              <label className=\"flex cursor-pointer items-start gap-2.5\">\n                <input\n                  type=\"checkbox\"\n                  checked={value.workingHoursOnly}\n                  onChange={(e) => update({ workingHoursOnly: e.target.checked })}\n                  aria-describedby={`${id}-hours-hint`}\n                  className=\"mt-0.5 size-3.5 shrink-0 cursor-pointer accent-foreground\"\n                />\n                <span className=\"flex flex-col gap-0.5\">\n                  <span className=\"text-xs\">Start wait time during working hours only</span>\n                  <span id={`${id}-hours-hint`} className=\"text-[11px] text-muted-foreground\">\n                    Requesters will be notified when maintainers are offline.\n                  </span>\n                </span>\n              </label>\n            </div>\n\n            {/* Live summary */}\n            <p\n              aria-live=\"polite\"\n              className=\"border-t bg-muted/40 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground\"\n            >\n              {agentName} waits <span className=\"font-medium text-foreground\">{formatWait(value.amount, value.unit)}</span>\n              {value.workingHoursOnly ? \" of working hours\" : \"\"}, then hands off to{\" \"}\n              <span className=\"font-medium text-foreground\">\n                {value.kind === \"team\" ? `the ${target?.label} team` : target?.label}\n              </span>\n              .\n            </p>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}