Iconiq
uiai-inputsource: ok · refreshed
AI Input
Chat-style AI composer with agent and model chips, sent-message bubbles, and an Apple Intelligence-style gradient wave on send.
npx shadcn add "https://iconiqui.com/r/ai-input.json"free
Where it goes
chat
Visual style
gradient
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@base-ui/react
animationmotion
iconslucide-react
Registry dependencies
b-switch
Files1 · source cached 10/2/2026, 1:58:54 AM
ai-input.tsx
"use client";
import { Input as InputPrimitive } from "@base-ui/react/input";
import {
ArrowUp,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Mic,
Plus,
} from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import {
type ComponentType,
Fragment,
type ReactNode,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal, flushSync } from "react-dom";
import { Switch } from "@/components/ui/b-switch";
const WAVE_DURATION_MS = 1000;
const MAX_TEXTAREA_HEIGHT = 132;
/** Spring used by the shared hover highlight that glides between plus-menu rows. */
const HOVER_SPRING = {
type: "spring",
stiffness: 460,
damping: 34,
mass: 0.58,
} as const;
/** Apple Intelligence-style spectrum used by the send wave. */
const WAVE_WASH_GRADIENT =
"linear-gradient(180deg, transparent, rgba(34,211,238,0.07), rgba(59,130,246,0.09), rgba(217,70,239,0.1), rgba(244,63,94,0.09), rgba(249,115,22,0.08), transparent)";
export type AIInputOption = {
value: string;
label: string;
};
export type AIInputMessage = {
id: number;
text: string;
};
// ─── Settings dropdown types ────────────────────────────────────────────────
export type PromptSettingOption = {
value: string;
label: string;
description?: string;
};
export type PromptSettingGroup = {
id: string;
label: string;
options: PromptSettingOption[];
/** Show the selected option as a featured row with description and checkmark. */
display?: "featured" | "submenu";
/** Optional submenu row label for picking other options (e.g. "More models"). */
moreMenuLabel?: string;
};
export type PromptMenuAction = {
label: string;
icon?: ReactNode;
onSelect: () => void;
};
// ─── Settings dropdown helpers ───────────────────────────────────────────────
function getDefaultSettings(
groups: PromptSettingGroup[],
defaults?: Record<string, string>
) {
return groups.reduce<Record<string, string>>((acc, group) => {
const preferred = defaults?.[group.id];
const hasPreferred = group.options.some((opt) => opt.value === preferred);
acc[group.id] =
hasPreferred && preferred ? preferred : (group.options[0]?.value ?? "");
return acc;
}, {});
}
function getOptionLabel(group: PromptSettingGroup, value: string) {
return group.options.find((option) => option.value === value)?.label ?? value;
}
function getSelectedOption(group: PromptSettingGroup, value: string) {
return group.options.find((option) => option.value === value);
}
const dropdownPanelClassName =
"fixed z-[400] w-[min(14.5rem,calc(100vw-1.5rem))] overflow-y-auto overscroll-contain rounded-xl border border-border/60 bg-card py-1 text-sm shadow-[0_10px_28px_-20px_rgba(0,0,0,0.14)] outline-none [-webkit-overflow-scrolling:touch]";
const DROPDOWN_SIDE_OFFSET = 8;
const DROPDOWN_VIEWPORT_MARGIN = 12;
const DROPDOWN_SUBMENU_OFFSET = 4;
const DROPDOWN_PANEL_WIDTH = 232;
const DROPDOWN_ESTIMATED_HEIGHT = 240;
const COMPACT_VIEWPORT_WIDTH = 640;
const DROPDOWN_MIN_PANEL_HEIGHT = 160;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function isCompactViewport() {
return window.innerWidth < COMPACT_VIEWPORT_WIDTH;
}
function getPanelWidth(viewportWidth: number) {
return Math.min(
DROPDOWN_PANEL_WIDTH,
viewportWidth - DROPDOWN_VIEWPORT_MARGIN * 2
);
}
function useCompactViewport() {
const [isCompact, setIsCompact] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(`(max-width: ${COMPACT_VIEWPORT_WIDTH - 1}px)`)
.matches;
});
useEffect(() => {
const mediaQuery = window.matchMedia(
`(max-width: ${COMPACT_VIEWPORT_WIDTH - 1}px)`
);
const update = () => setIsCompact(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, []);
return isCompact;
}
function usePrefersHover() {
const [prefersHover, setPrefersHover] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setPrefersHover(mediaQuery.matches);
update();
mediaQuery.addEventListener("change", update);
return () => mediaQuery.removeEventListener("change", update);
}, []);
return prefersHover;
}
function useIsMounted() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
}
function isInsideDropdownPanel(node: Node | null) {
return (
node instanceof Element &&
Boolean(node.closest("[data-prompt-dropdown-panel]"))
);
}
function useDropdownDismiss({
open,
onClose,
onEscape,
triggerRef,
}: {
open: boolean;
onClose: () => void;
onEscape?: () => boolean;
triggerRef: React.RefObject<HTMLElement | null>;
}) {
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (triggerRef.current?.contains(target)) return;
if (isInsideDropdownPanel(target)) return;
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
if (onEscape?.()) return;
onClose();
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, onEscape, open, triggerRef]);
}
function preventFocusSteal(event: React.MouseEvent) {
event.preventDefault();
}
function activateOnEnterOrSpace(
event: React.KeyboardEvent,
action: () => void
) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
action();
}
}
function ChevronDownIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="10"
viewBox="0 0 14 14"
width="10"
>
<path
d="M3.5 5.25L7 8.75L10.5 5.25"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
const BAR_XS = [1.5, 5.75, 10] as const;
const BAR_WIDTH = 2.5;
const BAR_BOTTOM = 12.5;
type EffortLevel = "low" | "medium" | "high";
const BAR_HEIGHTS = [4.5, 7.5, 10.5] as const;
const EFFORT_BAR_LEVELS: Record<
EffortLevel,
Array<{ height: number; visible: boolean }>
> = {
low: [
{ height: 3.25, visible: true },
{ height: 4.75, visible: true },
{ height: 0, visible: false },
],
medium: [
{ height: BAR_HEIGHTS[0], visible: true },
{ height: BAR_HEIGHTS[1], visible: true },
{ height: 0, visible: false },
],
high: [
{ height: BAR_HEIGHTS[0], visible: true },
{ height: BAR_HEIGHTS[1], visible: true },
{ height: BAR_HEIGHTS[2], visible: true },
],
};
function normalizeEffortLevel(value: string): EffortLevel {
if (value === "low" || value === "medium" || value === "high") return value;
return "medium";
}
function getEffortGroupId(groups: PromptSettingGroup[]) {
return (
groups.find((group) => group.id === "effort")?.id ??
groups.find((group) => group.display === "submenu")?.id
);
}
const SETTINGS_TRANSITION = {
type: "spring" as const,
stiffness: 380,
damping: 34,
};
function EffortBarsIcon({ level }: { level: EffortLevel }) {
const bars = EFFORT_BAR_LEVELS[level];
return (
<svg
aria-hidden="true"
fill="none"
height="14"
viewBox="0 0 14 14"
width="14"
>
{bars.map((bar, index) => (
<motion.rect
animate={{
height: bar.visible ? bar.height : 0,
opacity: bar.visible ? 1 : 0,
y: bar.visible ? BAR_BOTTOM - bar.height : BAR_BOTTOM,
}}
fill="currentColor"
initial={false}
key={BAR_XS[index]}
rx={1}
transition={SETTINGS_TRANSITION}
width={BAR_WIDTH}
x={BAR_XS[index]}
/>
))}
</svg>
);
}
const dropdownSubmenuTriggerClassName =
"relative flex min-h-9 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors focus-visible:outline-none";
function DropdownFeaturedRow({
description,
label,
}: {
description?: string;
label: string;
}) {
return (
<div className="flex items-start justify-between gap-2.5 px-2.5 py-2">
<div className="min-w-0">
<p className="font-medium text-foreground leading-tight">{label}</p>
{description ? (
<p className="mt-0.5 text-muted-foreground text-xs leading-snug">
{description}
</p>
) : null}
</div>
<Check
aria-hidden="true"
className="mt-0.5 size-4 shrink-0 text-brand"
strokeWidth={2.25}
/>
</div>
);
}
const dropdownOptionClassName =
"relative flex min-h-9 w-full cursor-pointer scroll-m-1 touch-manipulation items-center justify-between gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors focus-visible:text-foreground focus-visible:outline-none";
const dropdownOptionHighlightClassName =
"absolute inset-x-1 inset-y-0.5 rounded-lg bg-accent/65";
const dropdownHighlightSpring = {
type: "spring" as const,
stiffness: 600,
damping: 38,
};
const dropdownLabelSpring = {
type: "spring" as const,
stiffness: 360,
damping: 28,
mass: 0.55,
};
const dropdownCheckSpring = {
type: "spring" as const,
stiffness: 460,
damping: 24,
mass: 0.5,
};
const dropdownPanelSpring = {
type: "spring" as const,
stiffness: 420,
damping: 34,
mass: 0.72,
};
type SubmenuOrigin = "left" | "right" | "below";
type SubmenuPosition = {
left: number;
top: number;
maxHeight: number;
origin: SubmenuOrigin;
};
type DropdownSide = "top" | "bottom";
type DropdownPosition = {
left: number;
side: DropdownSide;
top?: number;
bottom?: number;
maxHeight: number;
};
function getMainDropdownMotion(side: DropdownSide, instantClose = false) {
const offsetY = side === "bottom" ? -10 : 10;
return {
animate: { opacity: 1, scale: 1, y: 0 },
exit: {
opacity: 0,
scale: instantClose ? 1 : 0.98,
y: instantClose ? 0 : offsetY * 0.65,
},
initial: { opacity: 0, scale: 0.96, y: offsetY },
transition: instantClose ? { duration: 0 } : dropdownPanelSpring,
};
}
function getMainDropdownInnerMotion(side: DropdownSide, instantClose = false) {
const offsetY = side === "bottom" ? 5 : -5;
return {
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: instantClose ? 0 : offsetY * 0.5 },
initial: { opacity: 0, y: offsetY },
transition: instantClose
? { duration: 0 }
: { duration: 0.22, ease: [0.22, 1, 0.36, 1] as const },
};
}
function getFlyoutSubmenuMotion(origin: SubmenuOrigin, instantClose = false) {
const offsetX = origin === "right" ? -12 : 12;
return {
animate: { opacity: 1, scale: 1, x: 0 },
exit: {
opacity: 0,
scale: instantClose ? 1 : 0.98,
x: instantClose ? 0 : offsetX * 0.55,
},
initial: { opacity: 0, scale: 0.96, x: offsetX },
transition: instantClose ? { duration: 0 } : dropdownPanelSpring,
};
}
function getSubmenuPosition(
triggerRect: DOMRect,
panelHeight: number,
panelWidth: number
) {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const effectiveWidth = Math.min(panelWidth, getPanelWidth(viewportWidth));
if (isCompactViewport()) {
const left = clamp(
triggerRect.left,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const top = triggerRect.bottom + DROPDOWN_SIDE_OFFSET;
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
viewportHeight - top - DROPDOWN_VIEWPORT_MARGIN
);
return { left, maxHeight, origin: "below" as const, top };
}
const spaceRight =
viewportWidth -
triggerRect.right -
DROPDOWN_SUBMENU_OFFSET -
DROPDOWN_VIEWPORT_MARGIN;
const openRight = spaceRight >= effectiveWidth;
const left = openRight
? triggerRect.right + DROPDOWN_SUBMENU_OFFSET
: triggerRect.left - effectiveWidth - DROPDOWN_SUBMENU_OFFSET;
const clampedLeft = clamp(
left,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const maxTop = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportHeight - panelHeight - DROPDOWN_VIEWPORT_MARGIN
);
const top = clamp(triggerRect.top, DROPDOWN_VIEWPORT_MARGIN, maxTop);
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
viewportHeight - top - DROPDOWN_VIEWPORT_MARGIN
);
return {
left: clampedLeft,
maxHeight,
origin: openRight ? ("right" as const) : ("left" as const),
top,
};
}
function getDropdownPosition({
align = "start",
panelHeight,
panelWidth,
triggerRect,
}: {
align?: "end" | "start";
panelHeight: number;
panelWidth: number;
triggerRect: DOMRect;
}): DropdownPosition {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const effectiveWidth = Math.min(panelWidth, getPanelWidth(viewportWidth));
const spaceBelow =
viewportHeight -
triggerRect.bottom -
DROPDOWN_SIDE_OFFSET -
DROPDOWN_VIEWPORT_MARGIN;
const spaceAbove =
triggerRect.top - DROPDOWN_SIDE_OFFSET - DROPDOWN_VIEWPORT_MARGIN;
const openBelow =
spaceBelow >= panelHeight
? true
: spaceAbove >= panelHeight
? false
: spaceBelow >= spaceAbove;
const desiredLeft =
align === "end" ? triggerRect.right - effectiveWidth : triggerRect.left;
const left = clamp(
desiredLeft,
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - effectiveWidth - DROPDOWN_VIEWPORT_MARGIN
);
const maxHeight = Math.max(
DROPDOWN_MIN_PANEL_HEIGHT,
openBelow ? spaceBelow : spaceAbove
);
if (openBelow) {
return {
left,
maxHeight,
side: "bottom",
top: triggerRect.bottom + DROPDOWN_SIDE_OFFSET,
};
}
return {
left,
maxHeight,
side: "top",
bottom: viewportHeight - triggerRect.top + DROPDOWN_SIDE_OFFSET,
};
}
function DropdownMenuDivider() {
return <hr className="mx-2 h-px border-0 bg-border" />;
}
function DropdownOption({
label,
selected,
onSelect,
activeItemId,
highlightLayoutId,
setActiveItemId,
}: {
label: string;
selected: boolean;
onSelect: () => void;
activeItemId: string | null;
highlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const isActive = activeItemId === itemId;
return (
<button
aria-checked={selected}
className={`${dropdownOptionClassName} ${selected ? "text-foreground" : "text-muted-foreground"}`}
onClick={onSelect}
onKeyDown={(event) => activateOnEnterOrSpace(event, onSelect)}
onMouseDown={preventFocusSteal}
onMouseEnter={() => setActiveItemId(itemId)}
onPointerMove={() => setActiveItemId(itemId)}
role="menuitemradio"
tabIndex={-1}
type="button"
>
{isActive ? (
<motion.span
className={dropdownOptionHighlightClassName}
layoutId={highlightLayoutId}
transition={dropdownHighlightSpring}
/>
) : null}
<motion.span
className="relative z-10 flex min-w-0 flex-1 items-center gap-2 truncate"
transition={dropdownLabelSpring}
>
{label}
</motion.span>
{selected ? (
<motion.span
animate={{ opacity: 1, scale: 1, y: 0 }}
className="relative z-10 flex size-5 shrink-0 items-center justify-center text-foreground"
initial={{ opacity: 0, scale: 0.96, y: 1 }}
transition={dropdownCheckSpring}
>
<Check className="h-4 w-4" />
</motion.span>
) : null}
</button>
);
}
function DropdownActionItem({
label,
icon,
shortcut,
onSelect,
activeItemId,
highlightLayoutId,
setActiveItemId,
}: {
label: string;
icon?: ReactNode;
shortcut?: string;
onSelect: () => void;
activeItemId: string | null;
highlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const isActive = activeItemId === itemId;
return (
<button
className={`${dropdownOptionClassName} text-foreground`}
onClick={onSelect}
onKeyDown={(event) => activateOnEnterOrSpace(event, onSelect)}
onMouseDown={preventFocusSteal}
onMouseEnter={() => setActiveItemId(itemId)}
onPointerMove={() => setActiveItemId(itemId)}
role="menuitem"
tabIndex={-1}
type="button"
>
{isActive ? (
<motion.span
className={dropdownOptionHighlightClassName}
layoutId={highlightLayoutId}
transition={dropdownHighlightSpring}
/>
) : null}
<motion.span
className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5 truncate"
transition={dropdownLabelSpring}
>
{icon ? (
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{label}</span>
</motion.span>
{shortcut ? (
<span className="relative z-10 shrink-0 text-muted-foreground text-xs tabular-nums">
{shortcut}
</span>
) : null}
</button>
);
}
function useSubmenuPosition({
isCompact,
isOpen,
submenuRef,
triggerRef,
}: {
isCompact: boolean;
isOpen: boolean;
submenuRef: React.RefObject<HTMLDivElement | null>;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}) {
const [submenuPosition, setSubmenuPosition] = useState<SubmenuPosition>({
left: 0,
maxHeight: DROPDOWN_ESTIMATED_HEIGHT,
origin: "right",
top: 0,
});
const updateSubmenuPosition = useCallback(() => {
const trigger = triggerRef.current;
const submenu = submenuRef.current;
if (!trigger) return;
const panelHeight = submenu?.offsetHeight ?? DROPDOWN_ESTIMATED_HEIGHT;
const panelWidth = submenu?.offsetWidth ?? DROPDOWN_PANEL_WIDTH;
setSubmenuPosition(
getSubmenuPosition(
trigger.getBoundingClientRect(),
panelHeight,
panelWidth
)
);
}, [submenuRef, triggerRef]);
useLayoutEffect(() => {
if (!isOpen || isCompact) return;
updateSubmenuPosition();
const submenu = submenuRef.current;
let observer: ResizeObserver | undefined;
if (submenu) {
observer = new ResizeObserver(updateSubmenuPosition);
observer.observe(submenu);
}
window.addEventListener("resize", updateSubmenuPosition);
window.addEventListener("scroll", updateSubmenuPosition, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updateSubmenuPosition);
window.removeEventListener("scroll", updateSubmenuPosition, true);
};
}, [isCompact, isOpen, submenuRef, updateSubmenuPosition]);
return { submenuPosition, updateSubmenuPosition };
}
function SubmenuInlinePanel({
isOpen,
submenuOptions,
}: {
isOpen: boolean;
submenuOptions: ReactNode;
}) {
if (!isOpen) return null;
return <div className="px-1 pb-1">{submenuOptions}</div>;
}
function SubmenuFlyoutPanel({
flyoutSubmenuMotion,
groupLabel,
isOpen,
itemId,
onOpenChange,
prefersHover,
setActiveItemId,
submenuHighlightLayoutId,
submenuId,
submenuOptions,
submenuPosition,
submenuRef,
triggerRef,
}: {
flyoutSubmenuMotion: ReturnType<typeof getFlyoutSubmenuMotion>;
groupLabel: string;
isOpen: boolean;
itemId: string;
onOpenChange: (open: boolean) => void;
prefersHover: boolean;
setActiveItemId: (itemId: string | null) => void;
submenuHighlightLayoutId: string;
submenuId: string;
submenuOptions: ReactNode;
submenuPosition: SubmenuPosition;
submenuRef: React.RefObject<HTMLDivElement | null>;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}) {
return (
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
animate={flyoutSubmenuMotion.animate}
aria-label={groupLabel}
className={dropdownPanelClassName}
data-prompt-dropdown-panel=""
exit={flyoutSubmenuMotion.exit}
initial={flyoutSubmenuMotion.initial}
key={submenuId}
onMouseEnter={() => setActiveItemId(itemId)}
onMouseLeave={
prefersHover
? (event) => {
const related = event.relatedTarget as Node | null;
if (triggerRef.current?.contains(related)) return;
if (isInsideDropdownPanel(related)) return;
onOpenChange(false);
}
: undefined
}
ref={submenuRef}
role="menu"
style={{
left: submenuPosition.left,
maxHeight: submenuPosition.maxHeight,
top: submenuPosition.top,
transformOrigin:
submenuPosition.origin === "right" ? "left top" : "right top",
zIndex: 401,
}}
transition={flyoutSubmenuMotion.transition}
>
<LayoutGroup id={`${submenuHighlightLayoutId}-group`}>
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: flyoutSubmenuMotion.initial.x > 0 ? 3 : -3,
}}
initial={{
opacity: 0,
y: flyoutSubmenuMotion.initial.x > 0 ? -3 : 3,
}}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
>
{submenuOptions}
</motion.div>
</LayoutGroup>
</motion.div>
) : null}
</AnimatePresence>
);
}
function DropdownSubmenu({
group,
selectedValue,
triggerLabel,
valueLabel,
icon,
submenuId,
isOpen,
onOpenChange,
onSelect,
activeItemId,
highlightLayoutId,
submenuHighlightLayoutId,
setActiveItemId,
}: {
group: PromptSettingGroup;
selectedValue: string;
triggerLabel: string;
valueLabel?: string;
icon?: ReactNode;
submenuId: string;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (value: string) => void;
activeItemId: string | null;
highlightLayoutId: string;
submenuHighlightLayoutId: string;
setActiveItemId: (itemId: string | null) => void;
}) {
const itemId = useId();
const triggerRef = useRef<HTMLButtonElement>(null);
const submenuRef = useRef<HTMLDivElement>(null);
const isCompact = useCompactViewport();
const prefersHover = usePrefersHover();
const mounted = useIsMounted();
const { submenuPosition, updateSubmenuPosition } = useSubmenuPosition({
isCompact,
isOpen,
submenuRef,
triggerRef,
});
const isActive = activeItemId === itemId || isOpen;
const flyoutSubmenuMotion = getFlyoutSubmenuMotion(submenuPosition.origin);
const wasCompactRef = useRef<boolean | null>(null);
useEffect(() => {
const previousCompact = wasCompactRef.current;
wasCompactRef.current = isCompact;
if (previousCompact === false && isCompact && isOpen) {
onOpenChange(false);
}
}, [isCompact, isOpen, onOpenChange]);
const openSubmenu = useCallback(() => {
if (isCompact) return;
updateSubmenuPosition();
onOpenChange(true);
}, [isCompact, onOpenChange, updateSubmenuPosition]);
const toggleSubmenu = useCallback(() => {
setActiveItemId(itemId);
onOpenChange(!isOpen);
}, [isOpen, itemId, onOpenChange, setActiveItemId]);
const handleTriggerMouseLeave = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const related = event.relatedTarget as Node | null;
if (submenuRef.current?.contains(related)) return;
if (isInsideDropdownPanel(related)) return;
if (isOpen) onOpenChange(false);
},
[isOpen, onOpenChange]
);
const handleOptionSelect = useCallback(
(value: string) => {
flushSync(() => {
onOpenChange(false);
});
onSelect(value);
},
[onOpenChange, onSelect]
);
const submenuOptions = (
<div className="space-y-0.5">
{group.options.map((option) => (
<DropdownOption
activeItemId={activeItemId}
highlightLayoutId={submenuHighlightLayoutId}
key={option.value}
label={option.label}
onSelect={() => handleOptionSelect(option.value)}
selected={selectedValue === option.value}
setActiveItemId={setActiveItemId}
/>
))}
</div>
);
return (
<>
<button
aria-expanded={isOpen}
aria-haspopup="menu"
className={`${dropdownSubmenuTriggerClassName} touch-manipulation text-foreground`}
onClick={toggleSubmenu}
onKeyDown={(event) => activateOnEnterOrSpace(event, toggleSubmenu)}
onMouseDown={preventFocusSteal}
onMouseEnter={
prefersHover
? () => {
setActiveItemId(itemId);
openSubmenu();
}
: undefined
}
onMouseLeave={prefersHover ? handleTriggerMouseLeave : undefined}
onPointerMove={prefersHover ? () => setActiveItemId(itemId) : undefined}
ref={triggerRef}
tabIndex={-1}
type="button"
>
{isActive ? (
<motion.span
className={dropdownOptionHighlightClassName}
layoutId={highlightLayoutId}
transition={dropdownHighlightSpring}
/>
) : null}
<motion.span
className="relative z-10 flex min-w-0 flex-1 items-center gap-2.5 truncate"
transition={dropdownLabelSpring}
>
{icon ? (
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="truncate">{triggerLabel}</span>
</motion.span>
<span className="relative z-10 flex shrink-0 items-center gap-1.5">
{valueLabel ? (
<span className="max-w-[5.5rem] truncate text-muted-foreground sm:max-w-none">
{valueLabel}
</span>
) : null}
<ChevronRight
aria-hidden="true"
className={`size-4 text-muted-foreground transition-transform ${isCompact && isOpen ? "rotate-90" : ""}`}
/>
</span>
</button>
{isCompact ? (
<SubmenuInlinePanel isOpen={isOpen} submenuOptions={submenuOptions} />
) : null}
{!isCompact && mounted
? createPortal(
<SubmenuFlyoutPanel
flyoutSubmenuMotion={flyoutSubmenuMotion}
groupLabel={group.label}
isOpen={isOpen}
itemId={itemId}
onOpenChange={onOpenChange}
prefersHover={prefersHover}
setActiveItemId={setActiveItemId}
submenuHighlightLayoutId={submenuHighlightLayoutId}
submenuId={submenuId}
submenuOptions={submenuOptions}
submenuPosition={submenuPosition}
submenuRef={submenuRef}
triggerRef={triggerRef}
/>,
document.body
)
: null}
</>
);
}
export function SettingsDropdown({
groups,
values,
menuActions = [],
onOpenChange,
onValueChange,
}: {
groups: PromptSettingGroup[];
values: Record<string, string>;
menuActions?: PromptMenuAction[];
onOpenChange?: (open: boolean) => void;
onValueChange: (groupId: string, value: string) => void;
}) {
const [open, setOpen] = useState(false);
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null);
const [activeItemId, setActiveItemId] = useState<string | null>(null);
const highlightLayoutId = useId();
const [position, setPosition] = useState<DropdownPosition>({
left: 0,
maxHeight: DROPDOWN_ESTIMATED_HEIGHT,
side: "bottom",
top: 0,
});
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const prefersHover = usePrefersHover();
const isCompact = useCompactViewport();
const mounted = useIsMounted();
const [instantDismiss, setInstantDismiss] = useState(false);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
if (!nextOpen) {
setActiveItemId(null);
setOpenSubmenuId(null);
}
},
[onOpenChange]
);
const dismissDropdown = useCallback(
(instant = false) => {
if (instant) setInstantDismiss(true);
handleOpenChange(false);
},
[handleOpenChange]
);
useEffect(() => {
if (open) setInstantDismiss(false);
}, [open]);
const handleSubmenuSelect = useCallback(
(groupId: string, value: string) => {
onValueChange(groupId, value);
setOpenSubmenuId(null);
if (!isCompact) dismissDropdown(true);
},
[dismissDropdown, isCompact, onValueChange]
);
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) return;
const triggerRect = trigger.getBoundingClientRect();
const panel = panelRef.current;
const panelHeight = panel?.offsetHeight ?? DROPDOWN_ESTIMATED_HEIGHT;
const panelWidth = panel?.offsetWidth ?? DROPDOWN_PANEL_WIDTH;
setPosition(getDropdownPosition({ panelHeight, panelWidth, triggerRect }));
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePosition();
const panel = panelRef.current;
let observer: ResizeObserver | undefined;
if (panel) {
observer = new ResizeObserver(() => updatePosition());
observer.observe(panel);
}
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleEscape = useCallback(() => {
if (openSubmenuId) {
setOpenSubmenuId(null);
return true;
}
return false;
}, [openSubmenuId]);
useDropdownDismiss({
onClose: () => handleOpenChange(false),
onEscape: handleEscape,
open,
triggerRef,
});
const toggleOpen = useCallback(() => {
if (open) {
handleOpenChange(false);
return;
}
updatePosition();
handleOpenChange(true);
}, [handleOpenChange, open, updatePosition]);
const selectedLabels = groups.map((group) =>
getOptionLabel(group, values[group.id] ?? "")
);
const triggerLabel = selectedLabels.join(", ");
const effortGroupId = getEffortGroupId(groups);
const effortLevel = normalizeEffortLevel(
effortGroupId ? (values[effortGroupId] ?? "medium") : "medium"
);
const mainDropdownMotion = getMainDropdownMotion(
position.side,
instantDismiss
);
const mainDropdownInnerMotion = getMainDropdownInnerMotion(
position.side,
instantDismiss
);
return (
<>
<button
aria-expanded={open}
aria-haspopup="menu"
aria-label={`Select settings: ${triggerLabel}`}
className="flex min-w-0 max-w-[calc(100%-3rem)] items-center gap-1.5 rounded-full py-1 text-sm transition-colors hover:text-foreground"
onClick={toggleOpen}
onMouseDown={(event) => event.preventDefault()}
ref={triggerRef}
type="button"
>
<span className="shrink-0 text-muted-foreground">
<EffortBarsIcon level={effortLevel} />
</span>
{groups.map((group, index) => {
const label = getOptionLabel(group, values[group.id] ?? "");
return (
<span
className={`truncate font-medium ${index === 0 ? "text-foreground" : "text-muted-foreground"}`}
key={group.id}
>
{label}
</span>
);
})}
<span
className={`text-muted-foreground transition-transform ${open ? "rotate-180 text-foreground" : ""}`}
>
<ChevronDownIcon />
</span>
</button>
{mounted &&
createPortal(
<AnimatePresence initial={false}>
{open ? (
<motion.div
animate={mainDropdownMotion.animate}
aria-label="Prompt settings"
className={dropdownPanelClassName}
data-prompt-dropdown-panel=""
exit={mainDropdownMotion.exit}
initial={mainDropdownMotion.initial}
key="prompt-settings-dropdown"
ref={panelRef}
role="menu"
style={{
left: position.left,
maxHeight: position.maxHeight,
transformOrigin:
position.side === "bottom" ? "top left" : "bottom left",
...(position.side === "bottom"
? { top: position.top }
: { bottom: position.bottom }),
}}
transition={mainDropdownMotion.transition}
>
<LayoutGroup>
<motion.div
animate={mainDropdownInnerMotion.animate}
className="space-y-0.5"
exit={mainDropdownInnerMotion.exit}
initial={mainDropdownInnerMotion.initial}
onMouseLeave={
prefersHover
? (event) => {
const related = event.relatedTarget as Node | null;
if (
related instanceof Element &&
related.closest("[data-prompt-dropdown-panel]")
) {
return;
}
setActiveItemId(null);
setOpenSubmenuId(null);
}
: undefined
}
transition={mainDropdownInnerMotion.transition}
>
{menuActions.map((action, index) => (
<DropdownActionItem
activeItemId={activeItemId}
highlightLayoutId={`${highlightLayoutId}-action-${index}`}
icon={action.icon}
key={`${action.label}-${index}`}
label={action.label}
onSelect={() => {
action.onSelect();
handleOpenChange(false);
}}
setActiveItemId={setActiveItemId}
/>
))}
{menuActions.length > 0 && groups.length > 0 ? (
<DropdownMenuDivider />
) : null}
{groups
.filter((group) => group.display === "featured")
.map((group) => {
const selected = getSelectedOption(
group,
values[group.id] ?? ""
);
if (!selected) return null;
return (
<DropdownFeaturedRow
description={selected.description}
key={`${group.id}-featured`}
label={selected.label}
/>
);
})}
{groups
.filter((group) => group.display === "submenu")
.map((group) => (
<Fragment key={group.id}>
<DropdownMenuDivider />
<DropdownSubmenu
activeItemId={activeItemId}
group={group}
highlightLayoutId={highlightLayoutId}
isOpen={openSubmenuId === group.id}
onOpenChange={(nextOpen) => {
setOpenSubmenuId(nextOpen ? group.id : null);
}}
onSelect={(value) => {
handleSubmenuSelect(group.id, value);
}}
selectedValue={values[group.id] ?? ""}
setActiveItemId={setActiveItemId}
submenuHighlightLayoutId={`${highlightLayoutId}-${group.id}-sub`}
submenuId={group.id}
triggerLabel={group.label}
valueLabel={getOptionLabel(
group,
values[group.id] ?? ""
)}
/>
</Fragment>
))}
{groups
.filter((group) => group.moreMenuLabel)
.map((group) => (
<Fragment key={`${group.id}-more`}>
<DropdownMenuDivider />
<DropdownSubmenu
activeItemId={activeItemId}
group={group}
highlightLayoutId={highlightLayoutId}
isOpen={openSubmenuId === `${group.id}-more`}
onOpenChange={(nextOpen) => {
setOpenSubmenuId(
nextOpen ? `${group.id}-more` : null
);
}}
onSelect={(value) => {
handleSubmenuSelect(group.id, value);
}}
selectedValue={values[group.id] ?? ""}
setActiveItemId={setActiveItemId}
submenuHighlightLayoutId={`${highlightLayoutId}-${group.id}-more-sub`}
submenuId={`${group.id}-more`}
triggerLabel={group.moreMenuLabel ?? group.label}
/>
</Fragment>
))}
</motion.div>
</LayoutGroup>
</motion.div>
) : null}
</AnimatePresence>,
document.body
)}
</>
);
}
// ─── Plus menu ───────────────────────────────────────────────────────────────
export type AIInputMenuItem = {
value: string;
/** Not needed for separators. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** "action" (default) fires onMenuSelect, "toggle" renders a switch, "separator" draws a divider. */
type?: "action" | "toggle" | "separator";
/** Initial state for toggle items. */
defaultChecked?: boolean;
/** Keyboard shortcut shown on the right of action items (e.g. "⌘U"). */
shortcut?: string;
/** Called when this action item is clicked, in addition to onMenuSelect. */
onClick?: () => void;
/** Called when this toggle item changes, in addition to onMenuToggle. */
onCheckedChange?: (checked: boolean) => void;
/** Nested items turn an action into a submenu (one level deep). */
items?: AIInputMenuItem[];
};
function OptionMenu({
align = "start",
ariaLabel,
chipClassName,
onChange,
options,
value,
}: {
align?: "start" | "end";
ariaLabel: string;
chipClassName: string;
onChange: (value: string) => void;
options: AIInputOption[];
value: string;
}) {
const [open, setOpen] = useState(false);
const [upward, setUpward] = useState(false);
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
const hoverLayoutId = useId();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (containerRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const selected =
options.find((option) => option.value === value) ?? options[0];
return (
<div className="relative min-w-0" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="menu"
aria-label={ariaLabel}
className={chipClassName}
onClick={() => {
if (!open && containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setUpward(window.innerHeight - rect.bottom < 160);
}
setHoveredValue(null);
setOpen((previous) => !previous);
}}
type="button"
>
<span className="truncate">{selected?.label}</span>
<ChevronDown
aria-hidden="true"
className={`size-3.5 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence>
{open ? (
<motion.div
animate={{ opacity: 1, scale: 1, y: 0 }}
aria-label={ariaLabel}
className={`absolute z-50 w-max min-w-52 max-w-[calc(100vw-2rem)] rounded-2xl border border-border bg-card p-1 shadow-[0_10px_28px_-18px_rgba(0,0,0,0.25)] ${align === "end" ? "right-0" : "left-0"} ${upward ? `bottom-full mb-2 ${align === "end" ? "origin-bottom-right" : "origin-bottom-left"}` : `top-full mt-2 ${align === "end" ? "origin-top-right" : "origin-top-left"}`}`}
exit={{ opacity: 0, scale: 0.97, y: upward ? 4 : -4 }}
initial={{ opacity: 0, scale: 0.96, y: upward ? 6 : -6 }}
onMouseLeave={() => setHoveredValue(null)}
role="menu"
transition={{ duration: 0.16, ease: "easeOut" }}
>
{options.map((option) => (
<button
aria-checked={option.value === selected?.value}
className="relative isolate flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm"
key={option.value}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
onMouseEnter={() => setHoveredValue(option.value)}
role="menuitemradio"
type="button"
>
{hoveredValue === option.value ? (
<motion.span
className="absolute inset-0 -z-10 rounded-[inherit] bg-accent/70"
layoutId={hoverLayoutId}
transition={HOVER_SPRING}
/>
) : null}
<span className="truncate">{option.label}</span>
{option.value === selected?.value ? (
<Check
aria-hidden="true"
className="size-4 shrink-0 text-foreground"
/>
) : null}
</button>
))}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
function SendWave({ reducedMotion }: { reducedMotion: boolean }) {
return (
<motion.div
animate={{ opacity: 1 }}
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-[inherit]"
exit={{ opacity: 0 }}
initial={{ opacity: 1 }}
transition={{ duration: 0.25, ease: "easeOut" }}
>
<motion.div
animate={reducedMotion ? { y: "-17%" } : { y: "-105%" }}
className="absolute inset-x-0 top-0 h-[150%] blur-xl"
initial={{ y: "55%" }}
style={{ background: WAVE_WASH_GRADIENT }}
transition={
reducedMotion ? { duration: 0 } : { duration: 0.7, ease: "easeOut" }
}
/>
</motion.div>
);
}
const PLUS_PANEL_HEIGHT = 260;
const plusSlideVariants = {
enter: (dir: number) => ({ opacity: 0, x: dir * 10 }),
visible: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir * -10 }),
};
function collectToggleDefaults(items: AIInputMenuItem[]) {
const defaults: Record<string, boolean> = {};
for (const item of items) {
if (item.type === "toggle") {
defaults[item.value] = item.defaultChecked ?? false;
}
for (const child of item.items ?? []) {
if (child.type === "toggle") {
defaults[child.value] = child.defaultChecked ?? false;
}
}
}
return defaults;
}
function PlusMenuIcon({
icon: Icon,
}: {
icon?: ComponentType<{ className?: string }>;
}) {
if (!Icon) return null;
return (
<span aria-hidden="true">
<Icon className="size-[15px] shrink-0 text-foreground/60" />
</span>
);
}
function PlusMenu({
items,
onSelect,
onToggle,
}: {
items: AIInputMenuItem[];
onSelect?: (value: string) => void;
onToggle?: (value: string, checked: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const [upward, setUpward] = useState(false);
const [toggles, setToggles] = useState<Record<string, boolean>>(() =>
collectToggleDefaults(items)
);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const [slideDir, setSlideDir] = useState(1);
const [hoveredValue, setHoveredValue] = useState<string | null>(null);
const hoverLayoutId = useId();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (containerRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
const handleOpen = () => {
if (!open) {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setUpward(window.innerHeight - rect.bottom < PLUS_PANEL_HEIGHT);
}
setActiveSubmenu(null);
setSlideDir(1);
setHoveredValue(null);
}
setOpen((previous) => !previous);
};
const handleToggle = (item: AIInputMenuItem, checked: boolean) => {
setToggles((previous) => ({ ...previous, [item.value]: checked }));
item.onCheckedChange?.(checked);
onToggle?.(item.value, checked);
};
const handleAction = (item: AIInputMenuItem) => {
item.onClick?.();
onSelect?.(item.value);
setOpen(false);
};
const openSub = (value: string) => {
setSlideDir(1);
setActiveSubmenu(value);
setHoveredValue(null);
};
const closeSub = () => {
setSlideDir(-1);
setActiveSubmenu(null);
setHoveredValue(null);
};
const hoverHighlight = (key: string) =>
hoveredValue === key ? (
<motion.span
className="absolute inset-0 -z-10 rounded-[inherit] bg-accent/70"
layoutId={hoverLayoutId}
transition={HOVER_SPRING}
/>
) : null;
const renderItem = (item: AIInputMenuItem, inSubmenu: boolean) => {
if (item.type === "separator") {
return <div className="mx-1 my-0.5 h-px bg-border" key={item.value} />;
}
if (item.type === "toggle") {
return (
<div
className="relative isolate flex items-center justify-between gap-3 rounded-xl px-3 py-1.5"
key={item.value}
onMouseEnter={() => setHoveredValue(item.value)}
>
{hoverHighlight(item.value)}
<div className="flex items-center gap-3">
<PlusMenuIcon icon={item.icon} />
<span className="text-sm">{item.label}</span>
</div>
<Switch
checked={toggles[item.value] ?? false}
onCheckedChange={(checked) => handleToggle(item, checked)}
size="sm"
/>
</div>
);
}
if (!inSubmenu && item.items && item.items.length > 0) {
return (
<button
className="relative isolate flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm"
key={item.value}
onClick={() => openSub(item.value)}
onMouseEnter={() => setHoveredValue(item.value)}
type="button"
>
{hoverHighlight(item.value)}
<div className="flex items-center gap-3">
<PlusMenuIcon icon={item.icon} />
{item.label}
</div>
<ChevronRight
aria-hidden="true"
className="size-3.5 text-foreground/40"
/>
</button>
);
}
return (
<button
className="relative isolate flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm"
key={item.value}
onClick={() => handleAction(item)}
onMouseEnter={() => setHoveredValue(item.value)}
type="button"
>
{hoverHighlight(item.value)}
<div className="flex items-center gap-3">
<PlusMenuIcon icon={item.icon} />
{item.label}
</div>
{item.shortcut ? (
<span className="shrink-0 text-muted-foreground text-xs">
{item.shortcut}
</span>
) : null}
</button>
);
};
const activeParent = activeSubmenu
? items.find((item) => item.value === activeSubmenu)
: undefined;
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={open}
aria-haspopup="menu"
aria-label="More options"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-foreground/70 transition-colors hover:text-foreground sm:size-9"
onClick={handleOpen}
type="button"
>
<Plus aria-hidden="true" className="size-4" />
</button>
<AnimatePresence>
{open ? (
<motion.div
animate={{ opacity: 1, scale: 1, y: 0 }}
aria-label="More options"
className={`absolute left-0 z-50 w-max min-w-64 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-border bg-card p-1 shadow-[0_10px_28px_-18px_rgba(0,0,0,0.25)] ${upward ? "bottom-full mb-2 origin-bottom-left" : "top-full mt-2 origin-top-left"}`}
exit={{ opacity: 0, scale: 0.97, y: upward ? 4 : -4 }}
initial={{ opacity: 0, scale: 0.96, y: upward ? 6 : -6 }}
onMouseLeave={() => setHoveredValue(null)}
role="menu"
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AnimatePresence custom={slideDir} initial={false} mode="wait">
{activeParent ? (
<motion.div
animate="visible"
custom={slideDir}
exit="exit"
initial="enter"
key={activeParent.value}
transition={{ duration: 0.13, ease: "easeOut" }}
variants={plusSlideVariants}
>
<button
className="relative isolate flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left font-medium text-sm"
onClick={closeSub}
onMouseEnter={() => setHoveredValue("__back")}
type="button"
>
{hoverHighlight("__back")}
<ChevronLeft
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
{activeParent.label}
</button>
<div className="mx-1 my-0.5 h-px bg-border" />
{(activeParent.items ?? []).map((item) =>
renderItem(item, true)
)}
</motion.div>
) : (
<motion.div
animate="visible"
custom={slideDir}
exit="exit"
initial="enter"
key="main"
transition={{ duration: 0.13, ease: "easeOut" }}
variants={plusSlideVariants}
>
{items.map((item) => renderItem(item, false))}
</motion.div>
)}
</AnimatePresence>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
// ─── Public component ────────────────────────────────────────────────────────
export type AIInputProps = {
/** Options for the agent chip on the left. */
agents?: AIInputOption[];
defaultAgent?: string;
/** Setting groups for the settings dropdown (model, effort, etc.). */
settingGroups?: PromptSettingGroup[];
defaultSettings?: Record<string, string>;
/** Action items shown at the top of the settings dropdown. */
menuActions?: PromptMenuAction[];
/** Items for the plus menu: actions, toggles, separators, and one-level submenus. */
menuItems?: AIInputMenuItem[];
placeholder?: string;
/** Render sent messages as chat bubbles above the composer. */
showMessages?: boolean;
onSend?: (
message: string,
meta: { agent: string; settings: Record<string, string> }
) => void;
onMicClick?: () => void;
/** Fired when the plus button is clicked and no menuItems are provided. */
onPlusClick?: () => void;
/** Fired when an action item (or submenu item) is selected in the plus menu. */
onMenuSelect?: (value: string) => void;
/** Fired when a toggle item in the plus menu changes. */
onMenuToggle?: (value: string, checked: boolean) => void;
/** Fired when the agent chip selection changes. */
onAgentChange?: (value: string) => void;
/** Fired when any setting in the settings dropdown changes. */
onSettingsChange?: (settings: Record<string, string>) => void;
className?: string;
};
export function AIInput({
agents = [],
defaultAgent,
settingGroups = [],
defaultSettings,
menuActions = [],
menuItems = [],
placeholder = "Ask for follow-up changes",
showMessages = true,
onSend,
onMicClick,
onPlusClick,
onMenuSelect,
onMenuToggle,
onAgentChange,
onSettingsChange,
className,
}: AIInputProps) {
const [value, setValue] = useState("");
const [messages, setMessages] = useState<AIInputMessage[]>([]);
const [waveRun, setWaveRun] = useState(0);
const [agent, setAgent] = useState(
() => defaultAgent ?? agents[0]?.value ?? ""
);
const [internalSettings, setInternalSettings] = useState(() =>
getDefaultSettings(settingGroups, defaultSettings)
);
const nextMessageIdRef = useRef(0);
const waveTimeoutRef = useRef<number | undefined>(undefined);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesRef = useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion() ?? false;
const hasValue = value.trim() !== "";
useEffect(() => () => window.clearTimeout(waveTimeoutRef.current), []);
const syncHeight = useCallback(() => {
const element = textareaRef.current;
if (!element) return;
element.style.height = "auto";
element.style.height = `${Math.min(element.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`;
}, []);
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (trimmed === "") return;
nextMessageIdRef.current += 1;
setMessages((previous) => [
...previous,
{ id: nextMessageIdRef.current, text: trimmed },
]);
onSend?.(trimmed, { agent, settings: internalSettings });
setValue("");
requestAnimationFrame(() => {
const element = textareaRef.current;
if (element) element.style.height = "auto";
messagesRef.current?.scrollTo({
top: messagesRef.current.scrollHeight,
behavior: reducedMotion ? "auto" : "smooth",
});
});
setWaveRun((run) => run + 1);
window.clearTimeout(waveTimeoutRef.current);
waveTimeoutRef.current = window.setTimeout(() => {
setWaveRun(0);
}, WAVE_DURATION_MS);
}, [agent, internalSettings, onSend, reducedMotion, value]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
},
[handleSend]
);
return (
<div className={`w-full ${className ?? ""}`}>
{showMessages && messages.length > 0 ? (
<div
className="mb-4 flex max-h-72 flex-col gap-2 overflow-y-auto overscroll-contain px-1 py-1"
ref={messagesRef}
>
<AnimatePresence initial={false}>
{messages.map((message) => (
<motion.div
animate={{ opacity: 1, scale: 1, y: 0 }}
className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-3xl rounded-br-lg bg-muted px-4 py-2.5 text-[15px] text-foreground leading-relaxed sm:text-sm"
initial={{ opacity: 0, scale: 0.96, y: 10 }}
key={message.id}
layout="position"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
>
{message.text}
</motion.div>
))}
</AnimatePresence>
</div>
) : null}
<div className="@container relative rounded-[28px] border border-border bg-card shadow-[0_10px_26px_-22px_rgba(0,0,0,0.14)]">
<AnimatePresence>
{waveRun > 0 ? (
<SendWave
key={`send-wave-${waveRun}`}
reducedMotion={reducedMotion}
/>
) : null}
</AnimatePresence>
<InputPrimitive
aria-label="Message"
className="block max-h-[132px] w-full resize-none bg-transparent px-4 pt-4 pb-1 text-base text-foreground leading-6 outline-none placeholder:text-muted-foreground sm:px-5 sm:text-sm"
onValueChange={(next) => {
setValue(next);
syncHeight();
}}
placeholder={placeholder}
render={
<textarea onKeyDown={handleKeyDown} ref={textareaRef} rows={1} />
}
value={value}
/>
<div className="flex items-center gap-1.5 px-2.5 pt-1 pb-2.5 sm:gap-2 sm:px-3 sm:pb-3">
{menuItems.length > 0 ? (
<PlusMenu
items={menuItems}
onSelect={onMenuSelect}
onToggle={onMenuToggle}
/>
) : (
<button
aria-label="Add attachment"
className="flex size-8 shrink-0 items-center justify-center rounded-full text-foreground/70 transition-colors hover:text-foreground sm:size-9"
onClick={onPlusClick}
type="button"
>
<Plus aria-hidden="true" className="size-4" />
</button>
)}
{agents.length > 0 ? (
<OptionMenu
ariaLabel="Select agent"
chipClassName="flex w-full min-w-14 items-center gap-1.5 rounded-full bg-muted px-3 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-accent sm:px-3.5 sm:py-2"
onChange={(next) => {
setAgent(next);
onAgentChange?.(next);
}}
options={agents}
value={agent}
/>
) : null}
<div className="ml-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
{settingGroups.length > 0 || menuActions.length > 0 ? (
<SettingsDropdown
groups={settingGroups}
menuActions={menuActions}
onValueChange={(groupId, val) => {
const next = { ...internalSettings, [groupId]: val };
setInternalSettings(next);
onSettingsChange?.(next);
}}
values={internalSettings}
/>
) : null}
<button
aria-label="Use voice input"
className="@[21rem]:flex hidden size-8 shrink-0 items-center justify-center rounded-full text-foreground/60 transition-colors hover:text-foreground sm:size-9"
onClick={onMicClick}
type="button"
>
<Mic aria-hidden="true" className="size-[18px]" />
</button>
<button
aria-label="Send message"
className={`flex size-8 shrink-0 items-center justify-center rounded-full transition-[background-color,opacity,transform] sm:size-9 ${hasValue ? "bg-foreground text-background hover:opacity-90" : "cursor-default bg-muted-foreground/50 text-background"}`}
disabled={!hasValue}
onClick={handleSend}
type="button"
>
<ArrowUp
aria-hidden="true"
className="size-4"
strokeWidth={2.25}
/>
</button>
</div>
</div>
</div>
</div>
);
}