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

- Category: agents
- Job: Oversee — Show progress and reasoning while the AI works, and let people approve, pause or stop it.
- Slug: `wait-time-handoff`
- HTML: https://ai-patterns.app/patterns/agents/wait-time-handoff
- JSON: https://ai-patterns.app/api/patterns/agents/wait-time-handoff
- Seen in: [Intercom](https://www.intercom.com/help/en/articles/7155449-customer-or-teammate-has-been-unresponsive)

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

## Implementation

Reference implementation (React, TypeScript, Tailwind v4, Motion, Radix). Keep every rule from the spec above; if the host project has its own design system, re-express the markup and motion in it rather than copying these classes.

npm dependencies: `lucide-react`, `motion`

Local imports to provide: `@/lib/utils` (`cn` is the usual `clsx` + `tailwind-merge` helper).

```tsx title="component.tsx"
"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&apos;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>
  );
}
```
