Components
UserAsk
Keyboard-first agent question panel that temporarily replaces the chat composer inside its container.
Composition
Anatomy
- <UserAsk>
- <UserAskHeader>
- <UserAskTitle />
- <UserAskPagination />
- <UserAskQuestion>
- <UserAskOption>
- <UserAskOptionLabel />
- <UserAskOptionDescription />
- <UserAskOptionInput />
- <UserAskOption>
- <UserAskFooter>
- <UserAskDismiss />
- <UserAskSubmit />
- <UserAskHeader>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
This agent installs from the user-ask registry. Install the bundle with the command above, or inspect the source below.
npx shadcn@latest add https://control-ui.dev/r/user-ask.jsonUsage
"use client";import { useChat } from "@mastra/react";import { UserAsk, UserAskDismiss, UserAskFooter, UserAskHeader, UserAskOption, UserAskOptionInput, UserAskOptionLabel, UserAskQuestion, UserAskSubmit, UserAskTitle,} from "@/components/control-ui/user-ask";export function Example({ agentId, toolCallId }: { agentId: string; toolCallId: string }) { const { approveToolCall, declineToolCall } = useChat({ agentId }); return ( <UserAsk autoFocus onComplete={(answers) => approveToolCall(toolCallId, answers)} onDismiss={() => declineToolCall(toolCallId)}> <UserAskHeader> <UserAskTitle /> </UserAskHeader> <UserAskQuestion id="scope" title="What should this run focus on?" defaultValue="fix"> <UserAskOption value="fix" recommended> <UserAskOptionLabel>Tiny repo fix</UserAskOptionLabel> </UserAskOption> <UserAskOption value="polish"> <UserAskOptionLabel>UI polish</UserAskOptionLabel> </UserAskOption> <UserAskOptionInput label="Something else" /> </UserAskQuestion> <UserAskFooter> <UserAskDismiss /> <UserAskSubmit /> </UserAskFooter> </UserAsk> );}Dependencies
Raw code
This agent’s owned source and private support files
"use client";import { Check, ChevronLeft, ChevronRight, PencilLine } from "lucide-react";import type { ChangeEvent, ComponentProps, CSSProperties, MouseEvent } from "react";import { createContext, useContext, useEffect, useId, useRef } from "react";import type { UserAskProps } from "@/components/control-ui/hooks/use-user-ask";import { useUserAsk } from "@/components/control-ui/hooks/use-user-ask";import type { UserAskKnobStyle } from "@/components/control-ui/knob-contracts/user-ask-knobs";import { cn } from "@/components/control-ui/lib/cn";import { Button } from "@/components/control-ui/ui/button";import { Kbd } from "@/components/control-ui/ui/kbd";type UserAskStyleProps<Props, Style> = Omit<Props, "style"> & { style?: CSSProperties & Style };type UserAskContextValue = ReturnType<typeof useUserAsk> & { titleId: string;};const UserAskContext = createContext<UserAskContextValue | null>(null);function useUserAskContext() { const context = useContext(UserAskContext); if (!context) throw new Error("UserAsk compound components must be rendered inside <UserAsk>."); return context;}type UserAskQuestionContextValue = { id: string; active: boolean; multiple: boolean;};const UserAskQuestionContext = createContext<UserAskQuestionContextValue | null>(null);function useUserAskQuestionContext() { const context = useContext(UserAskQuestionContext); if (!context) throw new Error("UserAsk options must be rendered inside <UserAskQuestion>."); return context;}export function UserAsk({ onComplete, onDismiss, autoFocus = false, className, children, ...props }: UserAskProps) { const ask = useUserAsk({ onComplete, onDismiss }); const titleId = useId(); const panelRef = useRef<HTMLElement>(null); // native autofocus only fires at document load, and this panel mounts mid-conversation useEffect(() => { if (autoFocus) panelRef.current?.focus(); }, [autoFocus]); // Hiding question blurs its focused descendants to <body> and kills Enter/Escape flow. // browser may not have processed that blur yet at effect time, so test visibility, not containment. const lastActiveIndex = useRef(ask.activeIndex); useEffect(() => { if (lastActiveIndex.current === ask.activeIndex) return; lastActiveIndex.current = ask.activeIndex; const panel = panelRef.current; const active = document.activeElement; const activeIsUsable = active instanceof HTMLElement && panel?.contains(active) && active.checkVisibility(); if (panel && !activeIsUsable) panel.focus(); }, [ask.activeIndex]); return ( <UserAskContext.Provider value={{ ...ask, titleId }}> {/* fx anchor candidate: "user-ask:panel-layer" */} <section ref={panelRef} data-control-ui="user-ask" data-control-family="user-ask" data-slot="root" data-surface="panel" aria-labelledby={titleId} tabIndex={-1} onKeyDown={ask.handleKeyDown} className={cn("relative w-full", className)} {...props} > {children} </section> </UserAskContext.Provider> );}export type UserAskHeaderProps = ComponentProps<"header"> & { style?: CSSProperties & UserAskKnobStyle };export function UserAskHeader({ className, ...props }: UserAskHeaderProps) { return ( <header data-control-ui="user-ask" data-control-family="user-ask" data-slot="header" className={cn("flex items-start justify-between", className)} {...props} /> );}export type UserAskTitleProps = UserAskStyleProps<ComponentProps<"h3">, UserAskKnobStyle>;export function UserAskTitle({ className, children, ...props }: UserAskTitleProps) { const ask = useUserAskContext(); return ( <h3 id={ask.titleId} data-control-ui="user-ask" data-control-family="user-ask" data-slot="title" className={className} {...props}> {children ?? ask.activeQuestion?.title} </h3> );}export type UserAskPaginationProps = UserAskStyleProps<ComponentProps<"div">, UserAskKnobStyle>;export function UserAskPagination({ className, ...props }: UserAskPaginationProps) { const ask = useUserAskContext(); if (ask.questions.length <= 1) return null; return ( <div data-control-ui="user-ask" data-control-family="user-ask" data-slot="pagination" className={cn("flex shrink-0 items-center", className)} {...props} > <Button type="button" variant="quiet" size="xs" aria-label="Previous question" disabled={ask.activeIndex === 0} onClick={() => ask.goTo(ask.activeIndex - 1)} > <ChevronLeft aria-hidden="true" className="size-3.5" /> </Button> <span data-control-ui="user-ask" data-control-family="user-ask" data-slot="pagination-count"> {ask.activeIndex + 1} of {ask.questions.length} </span> <Button type="button" variant="quiet" size="xs" aria-label="Next question" disabled={ask.isLastQuestion} onClick={() => ask.goTo(ask.activeIndex + 1)} > <ChevronRight aria-hidden="true" className="size-3.5" /> </Button> </div> );}export type UserAskQuestionProps = Omit<ComponentProps<"div">, "title"> & { id: string; title: string; /** Lets the question keep several answers; resolves to a string array. */ multiple?: boolean; /** Pre-selects options carrying these values (e.g. recommended one) until user picks another. */ defaultValue?: string | string[];} & { style?: CSSProperties & UserAskKnobStyle };export function UserAskQuestion({ id, title, multiple = false, defaultValue, className, children, ...props }: UserAskQuestionProps) { const ask = useUserAskContext(); const key = useId(); const active = ask.activeQuestion?.key === key; const defaultKey = Array.isArray(defaultValue) ? defaultValue.join("\u0000") : defaultValue; useEffect(() => { const defaultValues = defaultKey === undefined ? undefined : defaultKey.split("\u0000"); return ask.registerQuestion({ key, id, title, multiple, defaultValues }); }, [ask.registerQuestion, key, id, title, multiple, defaultKey]); useEffect(() => () => ask.unregisterQuestion(key), [ask.unregisterQuestion, key]); return ( <UserAskQuestionContext.Provider value={{ id, active, multiple }}> {/* inactive questions stay mounted so their registrations keep pagination and answers alive */} {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: role resolves to radiogroup or group at runtime; both support aria-labelledby. */} <div role={multiple ? "group" : "radiogroup"} aria-labelledby={active ? ask.titleId : undefined} hidden={!active} data-control-ui="user-ask" data-control-family="user-ask" data-slot="question" data-active={active ? "" : undefined} className={cn("flex flex-col", className)} {...props} > {children} </div> </UserAskQuestionContext.Provider> );}type UserAskOptionContextValue = { selected: boolean; recommended: boolean;};const UserAskOptionContext = createContext<UserAskOptionContextValue | null>(null);function useUserAskOptionContext() { const context = useContext(UserAskOptionContext); if (!context) throw new Error("UserAskOption parts must be rendered inside <UserAskOption>."); return context;}const optionRowClasses = "grid w-full grid-cols-[auto_minmax(0,1fr)] items-start";export type UserAskOptionProps = UserAskStyleProps<Omit<ComponentProps<"button">, "value">, UserAskKnobStyle> & { value: string; recommended?: boolean;};export function UserAskOption({ value, recommended = false, disabled = false, className, children, onClick, ...props}: UserAskOptionProps) { const ask = useUserAskContext(); const question = useUserAskQuestionContext(); const key = useId(); useEffect(() => ask.registerOption(question.id, { key, value, disabled }), [ask.registerOption, question.id, key, value, disabled]); useEffect(() => () => ask.unregisterOption(question.id, key), [ask.unregisterOption, question.id, key]); const index = ask.optionsFor(question.id).findIndex((entry) => entry.key === key); const selected = ask.isSelected(question.id, key); function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented) ask.select(question.id, key); } return ( <UserAskOptionContext.Provider value={{ selected, recommended }}> {/* biome-ignore lint/a11y/useAriaPropsSupportedByRole: role resolves to radio or checkbox at runtime; both support aria-checked. */} <button type="button" role={question.multiple ? "checkbox" : "radio"} aria-checked={selected} disabled={disabled} data-control-ui="user-ask" data-control-family="user-ask" data-slot="option" data-multiple={question.multiple ? "" : undefined} data-selected={selected ? "" : undefined} data-recommended={recommended ? "" : undefined} onClick={handleClick} className={cn(optionRowClasses, "disabled:cursor-not-allowed", className)} {...props} > <span aria-hidden="true" data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-indicator" data-multiple={question.multiple ? "" : undefined} data-selected={selected ? "" : undefined} className="inline-flex shrink-0 items-center justify-center" > {selected ? <Check className="size-3" /> : index + 1} </span> <span className="min-w-0">{children}</span> </button> </UserAskOptionContext.Provider> );}export type UserAskOptionLabelProps = UserAskStyleProps<ComponentProps<"span">, UserAskKnobStyle>;export function UserAskOptionLabel({ className, children, ...props }: UserAskOptionLabelProps) { const option = useUserAskOptionContext(); return ( <span data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-label" className={cn("block", className)} {...props}> {children} {option.recommended ? ( <span data-control-ui="user-ask" data-control-family="user-ask" data-slot="recommended"> {" "} (Recommended) </span> ) : null} </span> );}export type UserAskOptionDescriptionProps = UserAskStyleProps<ComponentProps<"span">, UserAskKnobStyle>;export function UserAskOptionDescription({ className, ...props }: UserAskOptionDescriptionProps) { return ( <span data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-description" className={cn("block", className)} {...props} /> );}export type UserAskOptionInputProps = UserAskStyleProps<Omit<ComponentProps<"input">, "value">, UserAskKnobStyle> & { /** Row label while unselected (option's "Other" affordance). */ label?: string;};export function UserAskOptionInput({ label = "Other", placeholder = "Type your own answer", disabled = false, className, onChange, style, ...props}: UserAskOptionInputProps) { const ask = useUserAskContext(); const question = useUserAskQuestionContext(); const key = useId(); const inputRef = useRef<HTMLInputElement>(null); useEffect( () => ask.registerOption(question.id, { key, value: "", disabled, freeform: true }), [ask.registerOption, question.id, key, disabled], ); useEffect(() => () => ask.unregisterOption(question.id, key), [ask.unregisterOption, question.id, key]); const index = ask.optionsFor(question.id).findIndex((entry) => entry.key === key); const selected = ask.isSelected(question.id, key); // digits, arrows, and clicks all land here — move caret in so typing starts immediately useEffect(() => { if (selected && question.active) inputRef.current?.focus(); }, [selected, question.active]); function handleChange(event: ChangeEvent<HTMLInputElement>) { onChange?.(event); if (!event.defaultPrevented) ask.setFreeformText(question.id, event.currentTarget.value); } if (!selected) { return ( // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role resolves to radio or checkbox at runtime; both support aria-checked. <button type="button" role={question.multiple ? "checkbox" : "radio"} aria-checked={false} disabled={disabled} data-control-ui="user-ask" data-control-family="user-ask" data-slot="option" data-multiple={question.multiple ? "" : undefined} data-freeform="" onClick={() => ask.select(question.id, key)} className={cn(optionRowClasses, "disabled:cursor-not-allowed", className)} style={style} > <span aria-hidden="true" data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-indicator" data-multiple={question.multiple ? "" : undefined} className="inline-flex shrink-0 items-center justify-center" > {index + 1} </span> <span data-control-ui="user-ask" data-control-family="user-ask" data-slot="freeform-label" className="min-w-0"> {label} </span> </button> ); } return ( <div data-control-ui="user-ask" data-control-family="user-ask" data-slot="option" data-multiple={question.multiple ? "" : undefined} data-selected="" data-freeform="" className={cn(optionRowClasses, className)} style={style} > {question.multiple ? ( // biome-ignore lint/a11y/useSemanticElements: the indicator doubles as the uncheck control for a row whose label is the text input beside it. <button type="button" role="checkbox" aria-checked={true} aria-label={`Clear ${label}`} data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-indicator" data-multiple="" data-selected="" onClick={() => ask.select(question.id, key)} className="inline-flex shrink-0 items-center justify-center" > <Check className="size-3" /> </button> ) : ( <span aria-hidden="true" data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-indicator" data-selected="" className="inline-flex shrink-0 items-center justify-center" > <PencilLine className="size-3" /> </span> )} <input {...props} ref={inputRef} type="text" aria-label={label} data-control-ui="user-ask" data-control-family="user-ask" data-slot="option-input" value={ask.freeformTextFor(question.id)} onChange={handleChange} placeholder={placeholder} className="min-w-0" style={style} /> </div> );}export type UserAskFooterProps = ComponentProps<"footer"> & { style?: CSSProperties & UserAskKnobStyle };export function UserAskFooter({ className, ...props }: UserAskFooterProps) { return ( <footer data-control-ui="user-ask" data-control-family="user-ask" data-slot="footer" className={cn("flex items-center justify-end", className)} {...props} /> );}export type UserAskDismissProps = ComponentProps<typeof Button>;export function UserAskDismiss({ className, children, onClick, ...props }: UserAskDismissProps) { const ask = useUserAskContext(); function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented) ask.dismiss(); } return ( <Button data-control-ui="user-ask" data-user-ask-dismiss="true" data-slot="dismiss" type="button" variant="quiet" size="xs" onClick={handleClick} className={cn(className)} {...props} > {children ?? ( <> Dismiss <Kbd>esc</Kbd> </> )} </Button> );}export type UserAskSubmitProps = ComponentProps<typeof Button>;export function UserAskSubmit({ className, children, disabled, onClick, ...props }: UserAskSubmitProps) { const ask = useUserAskContext(); function handleClick(event: MouseEvent<HTMLButtonElement>) { onClick?.(event); if (!event.defaultPrevented) ask.advance(); } return ( <Button data-control-ui="user-ask" data-user-ask-submit="true" data-slot="submit" type="button" variant="solid" tone="primary" size="xs" disabled={disabled ?? !ask.canContinue} onClick={handleClick} className={className} {...props} > {children ?? ( <> Continue <Kbd data-user-ask-submit-kbd="true">⏎</Kbd> </> )} </Button> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-user-ask-* · 17 knobsHow the cascade resolves--cui-user-ask-radius<length>var(--radius-popover)--cui-user-ask-padding<length>calc(var(--spacing) * 3)--cui-user-ask-backdrop-filter*blur(var(--backdrop-blur-popover))--cui-user-ask-indicator-radius<length>9999px--cui-user-ask-indicator-multiple-radius<length>var(--radius-sm)--cui-user-ask-background<color>var(--popover)--cui-user-ask-foreground<color>var(--popover-foreground)--cui-user-ask-border-color<color>var(--control-rim)--cui-user-ask-border-width<length>var(--control-rim-width)--cui-user-ask-shadow*var(--shadow-pop)--cui-user-ask-option-radius<length>max(
var(--radius-sm),
min(var(--radius-popup-item-fit), calc(var(--cui-user-ask-radius) - var(--cui-user-ask-padding)))
)--cui-user-ask-option-hover-background<color>var(--hover-fill)--cui-user-ask-option-selected-background<color>var(--active-fill)--cui-user-ask-indicator-background<color>var(--muted)--cui-user-ask-indicator-foreground<color>var(--muted-foreground)--cui-user-ask-input-background<color>transparent--cui-user-ask-input-foreground<color>var(--foreground)