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.