AI Patterns

agents

Human in the Loop Question

A compact dialog that pauses an agent mid-task to collect a structured choice — radio options, a free-text fallback, skip/continue controls, and multi-step pagination.

How should the agent handle ambiguous user intent?

1/3
"use client";

import * as React from "react";
import { motion } from "motion/react";
import { ChevronLeft, ChevronRight, X } from "lucide-react";

import { cn } from "@/lib/utils";
import { ToolCallChip } from "@/registry/loaders/tool-call-chip/component";

export interface HitlOption {
  id: string;
  label: string;
}

export interface HitlQuestion {
  id: string;
  question: string;
  options: HitlOption[];
  freeTextPlaceholder?: string;
}

export interface HumanInTheLoopProps {
  questions: HitlQuestion[];
  onSubmit?: (answers: Record<string, string>) => void;
  onSkip?: () => void;
  onClose?: () => void;
  /** Called ~500 ms after the resolved chip shows "success". */
  onDone?: () => void;
  className?: string;
}

export function HumanInTheLoop({
  questions,
  onSubmit,
  onSkip,
  onClose,
  onDone,
  className,
}: HumanInTheLoopProps) {
  const [step, setStep] = React.useState(0);
  const [answers, setAnswers] = React.useState<Record<string, string>>({});
  const [done, setDone] = React.useState(false);
  const [chipStatus, setChipStatus] = React.useState<"running" | "success">("running");

  React.useEffect(() => {
    if (!done) return;
    const t1 = setTimeout(() => setChipStatus("success"), 1200);
    const t2 = setTimeout(() => onDone?.(), 1700);
    return () => { clearTimeout(t1); clearTimeout(t2); };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [done]);

  const current = questions[step];
  const total = questions.length;
  const selected = answers[current.id];
  const isFreeText = selected?.startsWith("free:");
  const freeTextValue = isFreeText ? selected.slice(5) : "";

  function selectOption(optionId: string) {
    setAnswers((prev) => ({ ...prev, [current.id]: optionId }));
  }

  function setFreeText(value: string) {
    setAnswers((prev) => ({ ...prev, [current.id]: `free:${value}` }));
  }

  function handleContinue() {
    if (step < total - 1) {
      setStep((s) => s + 1);
    } else {
      setDone(true);
      onSubmit?.(answers);
    }
  }

  function handleSkip() {
    if (step < total - 1) {
      setStep((s) => s + 1);
    } else {
      onSkip?.();
    }
  }

  if (done) {
    return (
      <motion.div
        initial={{ opacity: 0, y: 4 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.18 }}
        className={cn("w-full max-w-xs", className)}
      >
        <ToolCallChip
          kind="code"
          verb="Resuming"
          target="agent task"
          status={chipStatus}
          result="Agent task resumed"
        />
      </motion.div>
    );
  }

  return (
    <motion.div
      key={step}
      initial={{ opacity: 0, y: 6 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.18 }}
      className={cn("w-full max-w-xs overflow-visible rounded-2xl border bg-card", className)}
    >
      {/* Header */}
      <div className="flex items-start gap-2 px-4 pt-4 pb-3">
        <p className="flex-1 text-xs font-semibold leading-snug">{current.question}</p>
        {onClose && (
          <button
            type="button"
            onClick={onClose}
            aria-label="Dismiss question"
            className="mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground"
          >
            <X className="size-3.5" />
          </button>
        )}
      </div>

      {/* Options */}
      <div className="space-y-0 px-4 pb-3">
        {current.options.map((option) => {
          const isSelected = selected === option.id;
          return (
            <button
              key={option.id}
              type="button"
              onClick={() => selectOption(option.id)}
              className="flex w-full items-center gap-2.5 rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent"
            >
              <span
                className={cn(
                  "flex size-4 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors",
                  isSelected
                    ? "border-foreground bg-foreground"
                    : "border-muted-foreground/40"
                )}
              >
                {isSelected && <span className="size-1.5 rounded-full bg-white" />}
              </span>
              <span
                className={cn(
                  "text-xs transition-colors",
                  isSelected ? "font-medium text-foreground" : "text-muted-foreground"
                )}
              >
                {option.label}
              </span>
            </button>
          );
        })}

        {/* Free-text fallback */}
        {current.freeTextPlaceholder !== undefined && (
          <div className="flex items-center gap-2.5 px-1 pt-1.5">
            <span
              className={cn(
                "flex size-4 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors",
                isFreeText && freeTextValue
                  ? "border-blue-500 bg-blue-500"
                  : "border-muted-foreground/40"
              )}
            >
              {isFreeText && freeTextValue && (
                <span className="size-1.5 rounded-full bg-white" />
              )}
            </span>
            <input
              type="text"
              value={freeTextValue}
              placeholder={current.freeTextPlaceholder}
              onChange={(e) => setFreeText(e.target.value)}
              onFocus={() => {
                if (!isFreeText) setFreeText("");
              }}
              className="flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground/50 outline-none focus:text-foreground"
            />
          </div>
        )}
      </div>

      {/* Footer */}
      <div className="flex items-center gap-2 border-t px-4 py-3">
        {/* Prev / step count / next */}
        <div className="flex items-center gap-0.5">
          <button
            type="button"
            onClick={() => setStep((s) => s - 1)}
            disabled={step === 0}
            aria-label="Previous question"
            className="rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
          >
            <ChevronLeft className="size-3.5" />
          </button>
          <span className="tabular-nums text-xs text-muted-foreground">
            {step + 1}/{total}
          </span>
          <button
            type="button"
            onClick={() => setStep((s) => s + 1)}
            disabled={step === total - 1}
            aria-label="Next question"
            className="rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
          >
            <ChevronRight className="size-3.5" />
          </button>
        </div>

        <div className="ml-auto flex items-center gap-2">
          <button
            type="button"
            onClick={handleSkip}
            className="rounded-full px-3.5 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
          >
            Skip
          </button>
          <button
            type="button"
            onClick={handleContinue}
            disabled={!selected || (isFreeText && !freeTextValue)}
            className="rounded-full bg-foreground px-4 py-1.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-40"
          >
            Continue
          </button>
        </div>
      </div>
    </motion.div>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Human in the Loop Question

## Summary
A compact dialog that pauses an agent mid-task to collect a structured choice from the user — a set of radio options, an optional free-text fallback, and skip/continue controls. When more than one question is needed, a step counter opens a dropdown list so the user can see what's coming and jump between questions. Once all questions are answered the dialog resolves into a compact confirmation row and the agent proceeds.

## When to use
- The agent has reached a decision point where the output depends on a preference only the user can supply (a strategy choice, a scope decision, a threshold).
- The agent needs input before taking an action that is hard to reverse or would require significant rework if the wrong assumption is made.
- You want to front-load a short sequence of configuration questions at the start of a long autonomous run, so the agent can work uninterrupted afterward.

## When not to use
- For binary yes/no confirmation of a specific pending action — use Tool Approval, which is purpose-built for that pattern and carries the literal call details the user needs to see.
- When the agent can make a safe, recoverable assumption and ask forgiveness rather than permission — unnecessary interruptions erode trust in the agent.
- For long or complex surveys; cap the sequence at three to five questions. If more input is required, break the task into distinct phases with a planning step first.
- As a substitute for proper onboarding; don't use this pattern to collect user preferences that should have been gathered during setup.

## Anatomy
- **Question header**: the question text and an optional close/dismiss button.
- **Option list**: radio-style buttons; each shows a filled circle when selected. Label text shifts from muted to full contrast on selection.
- **Free-text fallback** (optional): an inline text input that acts as a write-in option; selecting it deselects the preset options.
- **Step indicator**: a `n/total` counter with a chevron that opens a step-list dropdown; answered steps show a filled checkmark, the current step is highlighted, future steps are dimmed.
- **Action row**: Skip (ghost) and Continue (primary blue); Continue is disabled until a choice is made.
- **Resolved state**: after the final question, the dialog transitions to a single confirmation row ("Got it — continuing the task.").

## Behavior
- Clicking Continue advances to the next question with a short fade-slide transition, or submits on the final step.
- Clicking Skip advances without recording an answer; the agent uses a default for that question.
- The step-list dropdown lets the user jump back to any question and change their answer before submitting.
- The free-text input captures focus and activates as the selected option; its radio indicator fills when the field has content.
- The dialog entry animates in (fade + slide up) so it feels like an insertion in the flow rather than a blocking overlay.
- After submit, the dialog transitions to the resolved state and is replaced after a short delay by the agent's next output.

## Guardrails
- The Continue button must remain disabled until the user has made a selection — either a preset option or a free-text entry with at least one character. Never auto-advance on selection without an explicit tap.
- Skip must never silently drop a question that the agent requires to proceed. If a question is mandatory, remove Skip and show a hint explaining why an answer is needed.
- Cap the sequence at five questions. Beyond that, the interruption feels like a form rather than a clarification and should be replaced by a dedicated settings or planning step.
- Do not show this pattern for decisions the agent can safely reverse or re-ask later. Reserve it for choices that meaningfully fork the agent's path or whose cost to undo is high.
- Never pre-select an option on the user's behalf. A pre-selected radio implies a default; if a default is acceptable, document it in the agent's behavior and skip the question entirely.
- The free-text fallback must not be the only option. It signals "none of the above" — if every question needs an open answer, a chat prompt is more appropriate than this pattern.

## Content guidelines
- Questions should be concrete decision points, not open-ended prompts ("Which model should handle reasoning tasks?" not "What do you prefer?").
- Option labels should be short noun phrases or brief imperatives (five words or fewer); avoid starting with a verb that duplicates the question's verb.
- The free-text placeholder should be the literal string "Something else..." — this signals write-in without over-explaining.
- Skip implies the agent has a sensible default; don't show it if skipping the question would leave the agent in an undefined state.
- Keep the entire sequence to three questions or fewer when possible; if the sequence must be longer, show a progress indicator.

## Accessibility
- Each option must be a real `<button>` element — not a styled `<div>` — so it's reachable and activatable by keyboard.
- The step-list dropdown uses `aria-haspopup` and `aria-expanded` on its trigger, and closes on Escape or an outside click.
- Continue's `disabled` state must be communicated beyond color alone — the button text remains legible and the label unchanged.
- The free-text input must have a visible label or an `aria-label`; the placeholder alone is not sufficient for accessibility.
- Avoid auto-advancing on radio selection without an explicit Continue click — screen readers and keyboard users need a stable target.

## Related patterns
- **Tool Approval** handles the narrower case of approving or denying a specific pending tool call; Human in the Loop handles open-ended preference collection.
- **Create Agent** is a broader wizard pattern for structured multi-field setup; use it when the questions involve text fields, toggles, and pickers rather than single-choice options.