motokoui
uiai-prompt-inputsource: ok · hit
AI Prompt Input
A premium AI prompt composer with rotating placeholders, floating toolbar, and animated model selector.
npx shadcn add "https://www.motokoui.com/r/ai-prompt-input.json"free
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
iconslucide-react
Registry dependencies
utilsai-model-selector
Files1 · source cached 10/8/2026, 12:19:06 PM
registry/motokoui/ai-prompt-input.tsx
"use client"
import * as React from "react"
import {
ArrowUpIcon,
AudioLinesIcon,
CheckIcon,
GlobeIcon,
Loader2Icon,
MicIcon,
PlusIcon,
PuzzleIcon,
SquareIcon,
TelescopeIcon,
UnplugIcon,
UploadIcon,
XIcon,
} from "lucide-react"
import { AnimatePresence, motion, type HTMLMotionProps } from "motion/react"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import {
DEFAULT_AI_MODELS,
defaultSelectionFor,
ModelSelector,
ModelSelectorContent,
ModelSelectorTrigger,
ModelSelectorValue,
type AiModel,
type AiModelSelection,
} from "@/components/ui/ai-model-selector"
/* =============================================================================
* AiPromptInput — Motoko UI
*
* A premium AI prompt composer: auto-growing textarea, rotating placeholder,
* floating toolbar, Cursor-style model selector, and stateful send control.
*
* <AiPromptInput onSubmit={(value, selection) => console.log(value, selection)} />
* ============================================================================= */
// ---------------------------------------------------------------------------
// Motion tokens
// ---------------------------------------------------------------------------
const EASE = [0.2, 0, 0, 1] as const
const SPRING_SOFT = { type: "spring" as const, stiffness: 420, damping: 32 }
const SPRING_HEIGHT = { type: "spring" as const, stiffness: 380, damping: 34 }
const SPRING_PRESS = { type: "spring" as const, stiffness: 500, damping: 28 }
const SPRING_ICON = { type: "spring" as const, duration: 0.3, bounce: 0 }
const MENU_PANEL_CLASS = cn(
"bg-b-pop origin-bottom-left overflow-hidden rounded-2xl border-2 p-1.5",
"shadow-[0_8px_30px_-8px_rgba(8,8,8,0.18),0_2px_8px_-2px_rgba(8,8,8,0.08)]",
"dark:shadow-[0_8px_30px_-8px_rgba(0,0,0,0.45),0_2px_8px_-2px_rgba(0,0,0,0.3)]"
)
const CHIP_SURFACE_CLASS = cn(
"bg-b-surface1 text-t-primary inline-flex items-center gap-1.5 rounded-full py-1 pr-1 pl-2 text-xs font-medium",
"shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
)
const TOOLBAR_BTN_CLASS = cn(
"relative flex size-9 cursor-pointer items-center justify-center rounded-xl",
"transition-[background-color,color,box-shadow,opacity,transform] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
"hover:bg-b-surface1 hover:text-t-primary",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-40"
)
type PresenceProps = Pick<
HTMLMotionProps<"span">,
"initial" | "animate" | "exit" | "transition"
>
const FADE_ONLY: PresenceProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
}
const ICON_SWAP: PresenceProps = {
initial: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, scale: 0.25, filter: "blur(4px)" },
transition: SPRING_ICON,
}
function scaleBlurPresence(reduceMotion: boolean): PresenceProps {
if (reduceMotion) return FADE_ONLY
return {
initial: { opacity: 0, scale: 0.9, filter: "blur(4px)" },
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, scale: 0.9, filter: "blur(4px)" },
transition: SPRING_ICON,
}
}
function menuPresence(reduceMotion: boolean): PresenceProps {
if (reduceMotion) return FADE_ONLY
return {
initial: { opacity: 0, y: 6, scale: 0.96, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, y: 4, scale: 0.98, filter: "blur(2px)" },
transition: { duration: 0.2, ease: EASE },
}
}
function placeholderPresence(reduceMotion: boolean): PresenceProps {
if (reduceMotion) return FADE_ONLY
return {
initial: { opacity: 0, y: 6, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: -6, filter: "blur(4px)" },
transition: { duration: 0.35, ease: EASE },
}
}
function iconPresence(reduceMotion: boolean): PresenceProps {
return reduceMotion ? FADE_ONLY : ICON_SWAP
}
function statusPresence(reduceMotion: boolean): PresenceProps {
if (reduceMotion) return FADE_ONLY
return {
initial: { opacity: 0, scale: 0.92, filter: "blur(4px)" },
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, scale: 0.94, filter: "blur(4px)" },
transition: { type: "spring", duration: 0.35, bounce: 0 },
}
}
// ---------------------------------------------------------------------------
// Public types & defaults
// ---------------------------------------------------------------------------
export type { AiModel, AiModelSelection }
/** @deprecated Use `AiModel` from the model selector. */
export type AiPromptModel = AiModel
export type AiPromptSendStatus = "idle" | "loading" | "success"
export type DictationPhase = "idle" | "recording" | "processing"
export const DEFAULT_PLACEHOLDERS = [
"Ask anything...",
"Explain this React component...",
"Generate a landing page...",
"Fix this TypeScript error...",
"Create a pricing section...",
"Write SQL query...",
] as const
/** Default models — same set as `DEFAULT_AI_MODELS` from AI Model Selector. */
export const DEFAULT_MODELS: AiModel[] = DEFAULT_AI_MODELS
export interface AiPromptInputProps {
/** Controlled prompt value. */
value?: string
/** Uncontrolled initial value. */
defaultValue?: string
/** Fires on every value change. */
onChange?: (value: string) => void
/** Fires when the user sends (Enter or Send button). */
onSubmit?: (value: string, selection: AiModelSelection) => void
/** Rotating placeholder phrases. Falls back to Motoko defaults. */
placeholders?: readonly string[]
/** Interval between placeholder rotates, in ms. */
placeholderInterval?: number
/** Available models for the selector. */
models?: AiModel[]
/** Controlled model selection (id + effort / context / modes). */
modelSelection?: AiModelSelection
/** Uncontrolled initial model selection. */
defaultModelSelection?: AiModelSelection
/** Fires when the model selection changes. */
onModelSelectionChange?: (selection: AiModelSelection) => void
/** Disable the whole composer. */
disabled?: boolean
/** External send status — drives loader / success on the send button. */
status?: AiPromptSendStatus
/** Soft limit for the textarea `maxLength` attribute. */
maxLength?: number
/** Minimum textarea rows. */
minRows?: number
/** Maximum textarea rows before scrolling. */
maxRows?: number
/** Show the floating toolbar. */
showToolbar?: boolean
/** Show the + actions menu. */
showActions?: boolean
/** Show the model selector. */
showModelSelector?: boolean
/** Controlled deep research toggle. */
deepResearch?: boolean
/** Uncontrolled initial deep research. */
defaultDeepResearch?: boolean
/** Fires when deep research is toggled. */
onDeepResearchChange?: (active: boolean) => void
/** Controlled web search toggle. */
webSearch?: boolean
/** Uncontrolled initial web search. */
defaultWebSearch?: boolean
/** Fires when web search is toggled. */
onWebSearchChange?: (active: boolean) => void
/** Fires when Upload file is chosen. */
onUploadFile?: () => void
/** Fires when Skills is chosen. */
onSkills?: () => void
/** Fires when Connectors is chosen. */
onConnectors?: () => void
/**
* Optional transcript provider for mic dictation.
* Defaults to cycling Motoko demo phrases when omitted.
*/
getDictationTranscript?: () => string
/** Fires when mic dictation starts or stops. */
onDictationChange?: (listening: boolean) => void
/** Fires when talk-with-AI voice mode starts or stops. */
onVoiceChange?: (active: boolean) => void
/** Extra classes on the shell. */
className?: string
/** Extra classes on the textarea. */
textareaClassName?: string
/** Accessible name for the prompt field. */
"aria-label"?: string
/** Test id for the shell. */
"data-testid"?: string
}
// ---------------------------------------------------------------------------
// Shared utilities
// ---------------------------------------------------------------------------
function assignRef<T>(
node: T | null,
...refs: Array<React.Ref<T> | undefined>
) {
for (const ref of refs) {
if (typeof ref === "function") ref(node)
else if (ref) (ref as React.MutableRefObject<T | null>).current = node
}
}
function usePrefersReducedMotion() {
const [reduceMotion, setReduceMotion] = React.useState(false)
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
const sync = () => setReduceMotion(mq.matches)
sync()
mq.addEventListener("change", sync)
return () => mq.removeEventListener("change", sync)
}, [])
return reduceMotion
}
function useControllableState<T>({
value,
defaultValue,
onChange,
}: {
value: T | undefined
defaultValue: T
onChange?: (value: T) => void
}): [T, (next: T) => void] {
const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
const isControlled = value !== undefined
const current = isControlled ? value : uncontrolled
const setValue = React.useCallback(
(next: T) => {
if (!isControlled) setUncontrolled(next)
onChange?.(next)
},
[isControlled, onChange]
)
return [current, setValue]
}
type MenuCoords = { top: number; left: number }
/** Shared open/close, portal coords, outside-click, and Escape for anchored menus. */
function useAnchoredMenu(disabled = false) {
const [open, setOpen] = React.useState(false)
const [mounted, setMounted] = React.useState(false)
const [coords, setCoords] = React.useState<MenuCoords | null>(null)
const triggerRef = React.useRef<HTMLButtonElement>(null)
const contentRef = React.useRef<HTMLDivElement>(null)
const menuId = React.useId()
React.useEffect(() => {
setMounted(true)
}, [])
React.useLayoutEffect(() => {
if (!open) return
const update = () => {
const trigger = triggerRef.current
if (!trigger) return
const rect = trigger.getBoundingClientRect()
setCoords({
top: rect.top + window.scrollY - 8,
left: rect.left + window.scrollX,
})
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open])
React.useEffect(() => {
if (!open) return
const onPointer = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null
if (!target) return
const inTrigger = triggerRef.current?.contains(target)
const inContent = contentRef.current?.contains(target)
if (!inTrigger && !inContent) setOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
event.preventDefault()
setOpen(false)
triggerRef.current?.focus()
}
document.addEventListener("mousedown", onPointer)
document.addEventListener("touchstart", onPointer)
document.addEventListener("keydown", onKey)
return () => {
document.removeEventListener("mousedown", onPointer)
document.removeEventListener("touchstart", onPointer)
document.removeEventListener("keydown", onKey)
}
}, [open])
React.useEffect(() => {
if (disabled) setOpen(false)
}, [disabled])
return { open, setOpen, mounted, coords, triggerRef, contentRef, menuId }
}
function AnchoredMenuPortal({
mounted,
open,
coords,
contentRef,
id,
role,
"aria-label": ariaLabel,
reduceMotion,
className,
children,
}: {
mounted: boolean
open: boolean
coords: MenuCoords | null
contentRef: React.RefObject<HTMLDivElement | null>
id: string
role: "menu" | "listbox"
"aria-label": string
reduceMotion: boolean
className?: string
children: React.ReactNode
}) {
if (!mounted) return null
return createPortal(
<AnimatePresence>
{open && coords ? (
<motion.div
key={id}
ref={contentRef}
id={id}
role={role}
aria-label={ariaLabel}
{...menuPresence(reduceMotion)}
style={{
position: "absolute",
top: coords.top,
left: coords.left,
transform: "translateY(-100%)",
zIndex: 50,
}}
className={cn(MENU_PANEL_CLASS, className)}
>
{children}
</motion.div>
) : null}
</AnimatePresence>,
document.body
)
}
function MenuCheckmark({
visible,
reduceMotion,
className,
}: {
visible: boolean
reduceMotion: boolean
className?: string
}) {
return (
<AnimatePresence initial={false}>
{visible ? (
<motion.span
key="check"
{...scaleBlurPresence(reduceMotion)}
className={cn("flex shrink-0", className)}
>
<CheckIcon className="size-3.5" aria-hidden />
</motion.span>
) : null}
</AnimatePresence>
)
}
function IconSwapFrame({
swapKey,
reduceMotion,
children,
}: {
swapKey: string
reduceMotion: boolean
children: React.ReactNode
}) {
return (
<motion.span
key={swapKey}
{...iconPresence(reduceMotion)}
className="relative z-10 flex"
>
{children}
</motion.span>
)
}
// ---------------------------------------------------------------------------
// Rotating placeholder
// ---------------------------------------------------------------------------
function RotatingPlaceholder({
phrases,
interval,
active,
reduceMotion,
}: {
phrases: readonly string[]
interval: number
active: boolean
reduceMotion: boolean
}) {
const [index, setIndex] = React.useState(0)
const safePhrases = phrases.length > 0 ? phrases : DEFAULT_PLACEHOLDERS
const phraseCount = safePhrases.length
React.useEffect(() => {
if (!active || reduceMotion || phraseCount <= 1) return
const id = window.setInterval(() => {
setIndex((i) => (i + 1) % phraseCount)
}, interval)
return () => window.clearInterval(id)
}, [active, interval, reduceMotion, phraseCount])
const current = safePhrases[index % phraseCount] ?? safePhrases[0]
if (!active) return null
return (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 px-1"
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={current}
className="text-t-secondary/70 block truncate text-[15px] leading-7 sm:text-base"
{...placeholderPresence(reduceMotion)}
>
{current}
</motion.span>
</AnimatePresence>
</div>
)
}
// ---------------------------------------------------------------------------
// Active tool chip
// ---------------------------------------------------------------------------
function ActiveToolChip({
label,
icon,
onRemove,
reduceMotion,
}: {
label: string
icon: React.ReactNode
onRemove: () => void
reduceMotion: boolean
}) {
return (
<motion.span
layout
{...scaleBlurPresence(reduceMotion)}
className={CHIP_SURFACE_CLASS}
>
<span className="flex shrink-0 [&_svg]:size-3.5">{icon}</span>
<span>{label}</span>
<button
type="button"
aria-label={`Disable ${label}`}
onClick={onRemove}
className={cn(
"text-t-secondary hover:text-t-primary flex size-5 cursor-pointer items-center justify-center rounded-full",
"transition-colors duration-150 ease-[cubic-bezier(0.2,0,0,1)]",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none"
)}
>
<XIcon className="size-3" aria-hidden />
</button>
</motion.span>
)
}
// ---------------------------------------------------------------------------
// Plus actions menu
// ---------------------------------------------------------------------------
function actionsItemClass(active = false) {
return cn(
"relative flex w-full cursor-pointer items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-sm",
"transition-colors duration-150 ease-[cubic-bezier(0.2,0,0,1)]",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-b-surface1 text-t-primary"
: "text-t-secondary hover:bg-b-surface1/70 hover:text-t-primary"
)
}
function PlusActionsMenu({
disabled,
reduceMotion,
deepResearch,
webSearch,
onUploadFile,
onToggleDeepResearch,
onToggleWebSearch,
onSkills,
onConnectors,
}: {
disabled?: boolean
reduceMotion: boolean
deepResearch: boolean
webSearch: boolean
onUploadFile?: () => void
onToggleDeepResearch: () => void
onToggleWebSearch: () => void
onSkills?: () => void
onConnectors?: () => void
}) {
const { open, setOpen, mounted, coords, triggerRef, contentRef, menuId } =
useAnchoredMenu(disabled)
const runAndClose = (action?: () => void) => {
action?.()
setOpen(false)
}
return (
<div className="relative">
<motion.button
ref={triggerRef}
type="button"
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
aria-label="Open actions"
onClick={() => setOpen((v) => !v)}
whileHover={disabled ? undefined : { scale: 1.05, y: -1 }}
whileTap={disabled ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
TOOLBAR_BTN_CLASS,
"text-t-secondary",
open && "bg-b-surface1 text-t-primary"
)}
>
<motion.span
animate={{ rotate: open ? 45 : 0 }}
transition={{ duration: 0.2, ease: EASE }}
className="flex [&_svg]:size-4"
>
<PlusIcon aria-hidden />
</motion.span>
</motion.button>
<AnchoredMenuPortal
mounted={mounted}
open={open}
coords={coords}
contentRef={contentRef}
id={menuId}
role="menu"
aria-label="Actions"
reduceMotion={reduceMotion}
className="min-w-56"
>
<div className="flex flex-col gap-0.5">
<motion.button
type="button"
role="menuitem"
whileTap={{ scale: 0.98 }}
transition={SPRING_PRESS}
className={actionsItemClass()}
onClick={() => runAndClose(onUploadFile)}
>
<UploadIcon className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="font-medium">Upload file</span>
</motion.button>
<motion.button
type="button"
role="menuitemcheckbox"
aria-checked={deepResearch}
whileTap={{ scale: 0.98 }}
transition={SPRING_PRESS}
className={actionsItemClass(deepResearch)}
onClick={() => runAndClose(onToggleDeepResearch)}
>
<TelescopeIcon className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 font-medium">Deep research</span>
<MenuCheckmark visible={deepResearch} reduceMotion={reduceMotion} />
</motion.button>
<motion.button
type="button"
role="menuitemcheckbox"
aria-checked={webSearch}
whileTap={{ scale: 0.98 }}
transition={SPRING_PRESS}
className={actionsItemClass(webSearch)}
onClick={() => runAndClose(onToggleWebSearch)}
>
<GlobeIcon className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="min-w-0 flex-1 font-medium">Web search</span>
<MenuCheckmark visible={webSearch} reduceMotion={reduceMotion} />
</motion.button>
<div role="separator" className="bg-s-stroke2 my-1.5 h-px" />
<motion.button
type="button"
role="menuitem"
whileTap={{ scale: 0.98 }}
transition={SPRING_PRESS}
className={actionsItemClass()}
onClick={() => runAndClose(onSkills)}
>
<PuzzleIcon className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="font-medium">Skills</span>
</motion.button>
<motion.button
type="button"
role="menuitem"
whileTap={{ scale: 0.98 }}
transition={SPRING_PRESS}
className={actionsItemClass()}
onClick={() => runAndClose(onConnectors)}
>
<UnplugIcon className="size-4 shrink-0 opacity-70" aria-hidden />
<span className="font-medium">Connectors</span>
</motion.button>
</div>
</AnchoredMenuPortal>
</div>
)
}
// ---------------------------------------------------------------------------
// Dictation / voice UI
// ---------------------------------------------------------------------------
const WAVE_BARS = [0.35, 0.7, 0.45, 0.9, 0.55, 0.8, 0.4] as const
const DEMO_TRANSCRIPTS = [
"Explain how this Motoko component handles focus and motion.",
"Generate a pricing section with three tiers and a soft Motoko surface.",
"Refactor this TypeScript module to use stricter types.",
] as const
function DictationWaveform({
processing,
reduceMotion,
}: {
processing: boolean
reduceMotion: boolean
}) {
return (
<motion.div
layout
{...statusPresence(reduceMotion)}
className={cn(
"bg-b-surface1 text-t-primary flex h-9 items-center gap-2 rounded-xl px-2.5",
"shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
)}
aria-hidden
>
{processing ? (
<span className="text-t-secondary flex items-center gap-1.5 text-xs font-medium">
<Loader2Icon className="size-3.5 animate-spin" />
Processing
</span>
) : (
<div className="flex h-4 items-end gap-[3px]">
{WAVE_BARS.map((base, index) => (
<motion.span
key={index}
className="bg-t-primary w-[3px] origin-bottom rounded-full"
initial={{ height: 4 }}
animate={
reduceMotion
? { height: Math.max(4, base * 14) }
: {
height: [
Math.max(3, base * 6),
Math.max(8, base * 16),
Math.max(4, base * 9),
Math.max(10, base * 14),
],
}
}
transition={
reduceMotion
? { duration: 0 }
: {
duration: 0.7 + index * 0.08,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.05,
}
}
/>
))}
</div>
)}
</motion.div>
)
}
function VoiceModeBadge({ reduceMotion }: { reduceMotion: boolean }) {
return (
<motion.div
layout
{...statusPresence(reduceMotion)}
className={cn(
"bg-b-surface1 text-t-primary flex h-9 items-center gap-2 rounded-xl px-2.5",
"shadow-[inset_0_0_0_1px_rgba(123,123,123,0.12)]"
)}
role="status"
aria-live="polite"
>
<motion.span
className="flex"
animate={reduceMotion ? undefined : { opacity: [0.55, 1, 0.55] }}
transition={{ duration: 1.4, ease: EASE, repeat: Infinity }}
>
<AudioLinesIcon className="size-3.5" aria-hidden />
</motion.span>
<span className="text-xs font-medium tracking-tight">Voice mode</span>
</motion.div>
)
}
function MicButton({
disabled,
phase,
onToggle,
reduceMotion,
}: {
disabled?: boolean
phase: DictationPhase
onToggle: () => void
reduceMotion: boolean
}) {
const active = phase === "recording"
const processing = phase === "processing"
const label =
phase === "recording"
? "Stop recording"
: phase === "processing"
? "Processing voice"
: "Start dictation"
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={active}
aria-busy={processing || undefined}
disabled={disabled || processing}
onClick={onToggle}
whileHover={disabled || processing ? undefined : { scale: 1.05, y: -1 }}
whileTap={disabled || processing ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
TOOLBAR_BTN_CLASS,
"overflow-visible",
active || processing
? "bg-b-surface1 text-t-primary"
: "text-t-secondary"
)}
>
<AnimatePresence>
{active && !reduceMotion ? (
<motion.span
key="mic-ring"
aria-hidden
className="pointer-events-none absolute inset-0 rounded-xl border border-current"
initial={{ opacity: 0.45, scale: 1 }}
animate={{ opacity: 0, scale: 1.55 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: EASE, repeat: Infinity }}
/>
) : null}
</AnimatePresence>
<AnimatePresence mode="wait" initial={false}>
{processing ? (
<IconSwapFrame swapKey="processing" reduceMotion={reduceMotion}>
<Loader2Icon className="size-4 animate-spin" aria-hidden />
</IconSwapFrame>
) : active ? (
<IconSwapFrame swapKey="stop" reduceMotion={reduceMotion}>
<SquareIcon className="size-3.5 fill-current" aria-hidden />
</IconSwapFrame>
) : (
<IconSwapFrame swapKey="mic" reduceMotion={reduceMotion}>
<MicIcon className="size-4" aria-hidden />
</IconSwapFrame>
)}
</AnimatePresence>
</motion.button>
)
}
function ActionButton({
disabled,
status,
hasText,
talking,
onSend,
onTalkToggle,
reduceMotion,
}: {
disabled?: boolean
status: AiPromptSendStatus
hasText: boolean
talking: boolean
onSend: () => void
onTalkToggle: () => void
reduceMotion: boolean
}) {
const isLoading = status === "loading"
const isSuccess = status === "success"
const showSend = !talking && (hasText || isLoading || isSuccess)
// Talk/stop stays clickable even when the rest of the shell is locked
const isDisabled = talking ? false : disabled || isLoading
const label = talking
? "Stop voice conversation"
: isLoading
? "Sending"
: isSuccess
? "Sent"
: showSend
? "Send message"
: "Talk with AI"
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={talking || undefined}
aria-busy={isLoading || undefined}
disabled={isDisabled}
onClick={() => {
if (talking || !showSend) onTalkToggle()
else onSend()
}}
whileHover={
isDisabled ? undefined : { scale: 1.06, transition: SPRING_SOFT }
}
whileTap={isDisabled ? undefined : { scale: 0.94 }}
animate={{
scale: isSuccess && !talking ? [1, 1.08, 1] : 1,
}}
transition={SPRING_PRESS}
className={cn(
"relative flex size-10 cursor-pointer items-center justify-center overflow-hidden rounded-full",
"transition-[background-color,box-shadow,opacity,color] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
"disabled:pointer-events-none",
showSend || talking
? [
"bg-linear-to-b from-[#2C2C2C] to-[#282828] text-white",
"shadow-[inset_2px_0_8px_2px_rgba(248,248,248,0.18)]",
"hover:shadow-[0_0_0_4px_rgba(20,20,20,0.08),inset_2px_0_8px_2px_rgba(248,248,248,0)]",
"dark:from-[#f7f7f7] dark:to-white dark:text-black",
"dark:shadow-[inset_2px_0_8px_2px_rgba(24,24,24,0.1)]",
"dark:hover:shadow-[0_0_0_4px_rgba(255,255,255,0.1)]",
]
: "bg-b-surface1 text-t-secondary hover:text-t-primary",
talking &&
"shadow-[0_0_0_4px_rgba(20,20,20,0.1),inset_2px_0_8px_2px_rgba(248,248,248,0.18)] dark:shadow-[0_0_0_4px_rgba(255,255,255,0.12)]"
)}
>
<AnimatePresence>
{talking && !reduceMotion ? (
<>
<motion.span
key="ring-1"
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border border-current"
initial={{ opacity: 0.5, scale: 1 }}
animate={{ opacity: 0, scale: 1.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.4, ease: EASE, repeat: Infinity }}
/>
<motion.span
key="ring-2"
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border border-current"
initial={{ opacity: 0.35, scale: 1 }}
animate={{ opacity: 0, scale: 1.55 }}
exit={{ opacity: 0 }}
transition={{
duration: 1.4,
ease: EASE,
repeat: Infinity,
delay: 0.35,
}}
/>
</>
) : null}
</AnimatePresence>
<AnimatePresence mode="wait" initial={false}>
{talking ? (
<IconSwapFrame swapKey="stop" reduceMotion={reduceMotion}>
<SquareIcon className="size-3.5 fill-current" aria-hidden />
</IconSwapFrame>
) : isLoading ? (
<IconSwapFrame swapKey="loader" reduceMotion={reduceMotion}>
<Loader2Icon className="size-4 animate-spin" aria-hidden />
</IconSwapFrame>
) : isSuccess ? (
<IconSwapFrame swapKey="check" reduceMotion={reduceMotion}>
<CheckIcon className="size-4" aria-hidden />
</IconSwapFrame>
) : showSend ? (
<IconSwapFrame swapKey="arrow" reduceMotion={reduceMotion}>
<ArrowUpIcon className="size-4" aria-hidden />
</IconSwapFrame>
) : (
<IconSwapFrame swapKey="waves" reduceMotion={reduceMotion}>
<AudioLinesIcon className="size-4" aria-hidden />
</IconSwapFrame>
)}
</AnimatePresence>
</motion.button>
)
}
// ---------------------------------------------------------------------------
// Root
// ---------------------------------------------------------------------------
const AiPromptInput = React.forwardRef<HTMLTextAreaElement, AiPromptInputProps>(
(
{
value: valueProp,
defaultValue = "",
onChange,
onSubmit,
placeholders = DEFAULT_PLACEHOLDERS,
placeholderInterval = 3200,
models = DEFAULT_MODELS,
modelSelection: modelSelectionProp,
defaultModelSelection,
onModelSelectionChange,
disabled = false,
status = "idle",
maxLength = 4000,
minRows = 1,
maxRows = 8,
showToolbar = true,
showActions = true,
showModelSelector = true,
deepResearch: deepResearchProp,
defaultDeepResearch = false,
onDeepResearchChange,
webSearch: webSearchProp,
defaultWebSearch = false,
onWebSearchChange,
onUploadFile,
onSkills,
onConnectors,
getDictationTranscript,
onDictationChange,
onVoiceChange,
className,
textareaClassName,
"aria-label": ariaLabel = "AI prompt",
"data-testid": dataTestId,
},
ref
) => {
const [value, setValue] = useControllableState({
value: valueProp,
defaultValue,
onChange,
})
const initialSelection =
defaultModelSelection ??
(models[0] ? defaultSelectionFor(models[0]) : { id: "" })
const [modelSelection, setModelSelection] = useControllableState({
value: modelSelectionProp,
defaultValue: initialSelection,
onChange: onModelSelectionChange,
})
const [deepResearch, setDeepResearch] = useControllableState({
value: deepResearchProp,
defaultValue: defaultDeepResearch,
onChange: onDeepResearchChange,
})
const [webSearch, setWebSearch] = useControllableState({
value: webSearchProp,
defaultValue: defaultWebSearch,
onChange: onWebSearchChange,
})
const [focused, setFocused] = React.useState(false)
const [height, setHeight] = React.useState<number | "auto">("auto")
const reduceMotion = usePrefersReducedMotion()
const [dictationPhase, setDictationPhase] =
React.useState<DictationPhase>("idle")
const [talking, setTalking] = React.useState(false)
const valueRef = React.useRef(value)
valueRef.current = value
const transcriptIndex = React.useRef(0)
const processTimer = React.useRef<number | null>(null)
const getTranscriptRef = React.useRef(getDictationTranscript)
getTranscriptRef.current = getDictationTranscript
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
const mirrorRef = React.useRef<HTMLDivElement | null>(null)
const fieldId = React.useId()
const trimmed = value.trim()
const hasText = trimmed.length > 0
const showPlaceholder = value.length === 0 && !focused
const hasActiveTools = deepResearch || webSearch
const dictating = dictationPhase !== "idle"
const sessionLocked = dictating || talking
React.useEffect(() => {
if (!hasText || !talking) return
setTalking(false)
onVoiceChange?.(false)
}, [hasText, talking, onVoiceChange])
React.useEffect(() => {
return () => {
if (processTimer.current !== null) {
window.clearTimeout(processTimer.current)
}
}
}, [])
const resize = React.useCallback(() => {
const el = textareaRef.current
const mirror = mirrorRef.current
if (!el) return
const styles = window.getComputedStyle(el)
const lineHeight = Number.parseFloat(styles.lineHeight) || 28
const paddingY =
Number.parseFloat(styles.paddingTop) +
Number.parseFloat(styles.paddingBottom)
const minH = lineHeight * minRows + paddingY
const maxH = lineHeight * maxRows + paddingY
if (mirror) {
mirror.style.width = `${el.clientWidth}px`
mirror.textContent = value.endsWith("\n") ? `${value} ` : value || " "
const next = Math.min(Math.max(mirror.scrollHeight, minH), maxH)
setHeight(next)
el.style.overflowY = mirror.scrollHeight > maxH ? "auto" : "hidden"
} else {
el.style.height = "auto"
const next = Math.min(Math.max(el.scrollHeight, minH), maxH)
setHeight(next)
el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden"
}
}, [value, minRows, maxRows])
React.useLayoutEffect(() => {
resize()
}, [resize])
const submit = React.useCallback(() => {
if (disabled || status === "loading" || !trimmed) return
onSubmit?.(trimmed, modelSelection)
}, [disabled, status, trimmed, onSubmit, modelSelection])
const finishDictation = React.useCallback(() => {
setDictationPhase("processing")
onDictationChange?.(false)
if (processTimer.current !== null) {
window.clearTimeout(processTimer.current)
}
processTimer.current = window.setTimeout(() => {
const sample =
getTranscriptRef.current?.() ??
DEMO_TRANSCRIPTS[transcriptIndex.current % DEMO_TRANSCRIPTS.length]
transcriptIndex.current += 1
const current = valueRef.current
const nextValue = current.trim()
? `${current.trim()} ${sample}`
: sample
setValue(nextValue)
setDictationPhase("idle")
processTimer.current = null
}, 1100)
}, [onDictationChange, setValue])
const toggleDictation = React.useCallback(() => {
if (disabled || dictationPhase === "processing") return
if (dictationPhase === "recording") {
finishDictation()
return
}
if (talking) {
setTalking(false)
onVoiceChange?.(false)
}
setDictationPhase("recording")
onDictationChange?.(true)
}, [
disabled,
dictationPhase,
talking,
finishDictation,
onDictationChange,
onVoiceChange,
])
const toggleTalk = React.useCallback(() => {
if (disabled || dictationPhase === "processing") return
const next = !talking
if (next && dictationPhase === "recording") {
setDictationPhase("idle")
onDictationChange?.(false)
}
setTalking(next)
onVoiceChange?.(next)
}, [disabled, dictationPhase, talking, onVoiceChange, onDictationChange])
const onKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
submit()
}
}
return (
<motion.div
data-slot="ai-prompt-input"
data-focused={focused || undefined}
data-disabled={disabled || undefined}
data-status={status}
data-dictating={dictating || undefined}
data-dictation-phase={
dictationPhase === "idle" ? undefined : dictationPhase
}
data-talking={talking || undefined}
animate={
reduceMotion
? undefined
: {
boxShadow: focused
? "0 2px 8px rgba(8,8,8,0.06), 0 16px 48px -12px rgba(8,8,8,0.22)"
: "0 1px 2px rgba(8,8,8,0.04), 0 8px 24px -12px rgba(8,8,8,0.14)",
}
}
transition={{ duration: 0.28, ease: EASE }}
className={cn(
"bg-b-pop border-s-subtle relative w-full overflow-visible rounded-[1.75rem] border-2 p-3.5 sm:p-4",
"transition-[background-color,opacity] duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
disabled && "pointer-events-none opacity-55",
talking && "ring-t-primary/15 ring-2",
className
)}
data-testid={dataTestId}
>
<div
ref={mirrorRef}
aria-hidden
className="invisible absolute top-0 left-0 -z-10 px-1 text-[15px] leading-7 break-words whitespace-pre-wrap sm:text-base"
/>
<AnimatePresence initial={false}>
{hasActiveTools ? (
<motion.div
key="active-tools"
initial={false}
className="mb-2.5 flex flex-wrap items-center gap-1.5 px-0.5"
>
<AnimatePresence initial={false} mode="popLayout">
{deepResearch ? (
<ActiveToolChip
key="deep-research"
label="Deep research"
icon={<TelescopeIcon aria-hidden />}
reduceMotion={reduceMotion}
onRemove={() => setDeepResearch(false)}
/>
) : null}
{webSearch ? (
<ActiveToolChip
key="web-search"
label="Web search"
icon={<GlobeIcon aria-hidden />}
reduceMotion={reduceMotion}
onRemove={() => setWebSearch(false)}
/>
) : null}
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
<div
className={cn(
"relative min-h-7 transition-opacity duration-200 ease-[cubic-bezier(0.2,0,0,1)]",
talking && "pointer-events-none opacity-40"
)}
>
<RotatingPlaceholder
phrases={placeholders}
interval={placeholderInterval}
active={showPlaceholder && !talking}
reduceMotion={reduceMotion}
/>
<motion.textarea
id={fieldId}
ref={(node) => assignRef(node, ref, textareaRef)}
value={value}
disabled={disabled || sessionLocked}
rows={minRows}
maxLength={maxLength}
aria-label={ariaLabel}
aria-multiline="true"
onChange={(event) => setValue(event.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
animate={
reduceMotion
? undefined
: { height: typeof height === "number" ? height : undefined }
}
transition={SPRING_HEIGHT}
className={cn(
"text-t-primary relative z-10 block w-full resize-none bg-transparent px-1",
"text-[15px] leading-7 sm:text-base",
"placeholder:text-transparent",
"outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
"caret-t-primary",
textareaClassName
)}
style={
reduceMotion && typeof height === "number"
? { height }
: undefined
}
/>
</div>
<AnimatePresence initial={false}>
{showToolbar ? (
<motion.div
key="toolbar"
initial={false}
animate={{
opacity:
focused ||
value.length > 0 ||
hasActiveTools ||
dictating ||
talking
? 1
: 0.78,
y: 0,
}}
transition={{ duration: 0.22, ease: EASE }}
className="border-s-stroke2 mt-3 flex items-center justify-between gap-2 border-t pt-3 sm:mt-3.5"
>
<div
className={cn(
"flex min-w-0 items-center gap-0.5 transition-opacity duration-200 ease-[cubic-bezier(0.2,0,0,1)] sm:gap-1",
sessionLocked && "pointer-events-none opacity-40"
)}
>
{showActions ? (
<PlusActionsMenu
disabled={disabled || sessionLocked}
reduceMotion={reduceMotion}
deepResearch={deepResearch}
webSearch={webSearch}
onUploadFile={onUploadFile}
onToggleDeepResearch={() => setDeepResearch(!deepResearch)}
onToggleWebSearch={() => setWebSearch(!webSearch)}
onSkills={onSkills}
onConnectors={onConnectors}
/>
) : null}
{showModelSelector && models.length > 0 ? (
<ModelSelector
models={models}
value={modelSelection}
onValueChange={setModelSelection}
disabled={disabled || sessionLocked}
>
<ModelSelectorTrigger className="h-9">
<ModelSelectorValue className="max-w-40 sm:max-w-52" />
</ModelSelectorTrigger>
<ModelSelectorContent side="top" />
</ModelSelector>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
<AnimatePresence initial={false}>
{dictating ? (
<DictationWaveform
key="dictation-wave"
processing={dictationPhase === "processing"}
reduceMotion={reduceMotion}
/>
) : null}
{talking ? (
<VoiceModeBadge
key="voice-mode"
reduceMotion={reduceMotion}
/>
) : null}
</AnimatePresence>
<MicButton
disabled={disabled || status === "loading" || talking}
phase={dictationPhase}
onToggle={toggleDictation}
reduceMotion={reduceMotion}
/>
<ActionButton
disabled={disabled || dictationPhase !== "idle"}
status={status}
hasText={hasText}
talking={talking}
onSend={submit}
onTalkToggle={toggleTalk}
reduceMotion={reduceMotion}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
</motion.div>
)
}
)
AiPromptInput.displayName = "AiPromptInput"
export { AiPromptInput }