"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
ArrowUp,
AtSign,
Check,
ChevronDown,
Code2,
FileText,
Globe,
Hash,
Lightbulb,
Mic,
Paperclip,
RotateCcw,
Slash,
Sparkles,
Square,
Upload,
User,
X,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface ComposerCommand {
id: string;
label: string;
description?: string;
icon?: LucideIcon;
}
export interface ComposerContext {
id: string;
label: string;
kind?: "file" | "doc" | "person" | "channel";
description?: string;
}
export interface ComposerModel {
id: string;
label: string;
description?: string;
badge?: string;
}
export interface ComposerTool {
id: string;
label: string;
icon: LucideIcon;
}
export interface ComposerAttachment {
id: string;
name: string;
size: number;
type: string;
file: File;
previewUrl?: string;
}
export interface ComposerSubmission {
text: string;
command: ComposerCommand | null;
contexts: ComposerContext[];
attachments: ComposerAttachment[];
model: string;
tools: string[];
}
export interface AIPromptComposerProps {
onSubmit?: (value: ComposerSubmission) => void;
onStop?: () => void;
onRegenerate?: () => void;
/** Shows the stop button while a response is streaming. */
generating?: boolean;
/** Shows the regenerate button when idle. */
canRegenerate?: boolean;
placeholder?: string;
commands?: ComposerCommand[];
contexts?: ComposerContext[];
models?: ComposerModel[];
tools?: ComposerTool[];
defaultModel?: string;
defaultTools?: string[];
/** Max height of the text area in px before it scrolls. */
maxHeight?: number;
/** Use the browser speech recognizer for dictation when available. */
speechRecognition?: boolean;
disabled?: boolean;
className?: string;
}
const DEFAULT_COMMANDS: ComposerCommand[] = [
{ id: "summarize", label: "summarize", description: "Condense the context into key points", icon: FileText },
{ id: "explain", label: "explain", description: "Explain step by step, like a tutor", icon: Lightbulb },
{ id: "code", label: "code", description: "Write or refactor code", icon: Code2 },
{ id: "research", label: "research", description: "Search sources and cite them", icon: Globe },
{ id: "brainstorm", label: "brainstorm", description: "Generate ten divergent ideas", icon: Sparkles },
];
const DEFAULT_CONTEXTS: ComposerContext[] = [
{ id: "roadmap", label: "Q3 roadmap.md", kind: "doc", description: "Docs · edited 2h ago" },
{ id: "pricing", label: "pricing-page.tsx", kind: "file", description: "src/app/pricing" },
{ id: "maya", label: "Maya Chen", kind: "person", description: "Design lead" },
{ id: "launch", label: "#launch-orbit", kind: "channel", description: "42 members" },
{ id: "metrics", label: "weekly-metrics.csv", kind: "file", description: "Uploaded Monday" },
];
const DEFAULT_MODELS: ComposerModel[] = [
{ id: "lumen-pro", label: "Lumen Pro", description: "Best for complex reasoning", badge: "New" },
{ id: "lumen-fast", label: "Lumen Fast", description: "Quick answers, low latency" },
{ id: "lumen-vision", label: "Lumen Vision", description: "Images, charts and screenshots" },
];
const DEFAULT_TOOLS: ComposerTool[] = [
{ id: "web", label: "Search", icon: Globe },
{ id: "think", label: "Think", icon: Lightbulb },
{ id: "code", label: "Code", icon: Code2 },
];
const CONTEXT_ICON: Record<NonNullable<ComposerContext["kind"]>, LucideIcon> = {
file: Code2,
doc: FileText,
person: User,
channel: Hash,
};
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
interface MenuState {
kind: "command" | "mention";
query: string;
start: number;
end: number;
}
// Minimal typing for the Web Speech API (not in lib.dom for all targets).
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
start: () => void;
stop: () => void;
onresult: ((e: { resultIndex: number; results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }> }) => void) | null;
onend: (() => void) | null;
onerror: (() => void) | null;
}
type SpeechCtor = new () => SpeechRecognitionLike;
let uid = 0;
const nextId = () => `att-${++uid}`;
export function AIPromptComposer({
onSubmit,
onStop,
onRegenerate,
generating = false,
canRegenerate = false,
placeholder = "Ask anything, / for commands, @ to add context",
commands = DEFAULT_COMMANDS,
contexts = DEFAULT_CONTEXTS,
models = DEFAULT_MODELS,
tools = DEFAULT_TOOLS,
defaultModel,
defaultTools = ["web"],
maxHeight = 200,
speechRecognition = true,
disabled = false,
className,
}: AIPromptComposerProps) {
const reduce = useReducedMotion();
const baseId = React.useId();
const taRef = React.useRef<HTMLTextAreaElement>(null);
const fileRef = React.useRef<HTMLInputElement>(null);
const [text, setText] = React.useState("");
const [command, setCommand] = React.useState<ComposerCommand | null>(null);
const [picked, setPicked] = React.useState<ComposerContext[]>([]);
const [attachments, setAttachments] = React.useState<ComposerAttachment[]>([]);
const [model, setModel] = React.useState(defaultModel ?? models[0]?.id ?? "");
const [activeTools, setActiveTools] = React.useState<string[]>(defaultTools);
const [menu, setMenu] = React.useState<MenuState | null>(null);
const [menuIndex, setMenuIndex] = React.useState(0);
const [modelOpen, setModelOpen] = React.useState(false);
const [dragging, setDragging] = React.useState(false);
const [listening, setListening] = React.useState(false);
const lastPrompt = React.useRef("");
const recognizer = React.useRef<SpeechRecognitionLike | null>(null);
const dragDepth = React.useRef(0);
const modelWrap = React.useRef<HTMLDivElement>(null);
const attachRef = React.useRef(attachments);
React.useEffect(() => {
attachRef.current = attachments;
}, [attachments]);
// Revoke preview URLs on unmount.
React.useEffect(() => () => attachRef.current.forEach((a) => a.previewUrl && URL.revokeObjectURL(a.previewUrl)), []);
// Auto-grow.
React.useLayoutEffect(() => {
const ta = taRef.current;
if (!ta) return;
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, maxHeight)}px`;
ta.style.overflowY = ta.scrollHeight > maxHeight ? "auto" : "hidden";
}, [text, maxHeight]);
// Close model menu on outside click.
React.useEffect(() => {
if (!modelOpen) return;
const onDown = (e: PointerEvent) => {
if (!modelWrap.current?.contains(e.target as Node)) setModelOpen(false);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [modelOpen]);
React.useEffect(() => () => recognizer.current?.stop(), []);
const menuItems = React.useMemo(() => {
if (!menu) return [];
const q = menu.query.toLowerCase();
if (menu.kind === "command") return commands.filter((c) => c.label.toLowerCase().includes(q)).slice(0, 6);
return contexts.filter((c) => c.label.toLowerCase().includes(q) && !picked.some((p) => p.id === c.id)).slice(0, 6);
}, [menu, commands, contexts, picked]);
const detectMenu = (value: string, caret: number) => {
const before = value.slice(0, caret);
const m = /(^|\s)([/@])([\w.-]*)$/.exec(before);
if (!m) return setMenu(null);
const trigger = m[2];
const start = caret - m[3].length - 1;
if (trigger === "/" && (start !== 0 || command)) return setMenu(null);
setMenu({ kind: trigger === "/" ? "command" : "mention", query: m[3], start, end: caret });
setMenuIndex(0);
};
const removeToken = (m: MenuState) => {
const next = (text.slice(0, m.start) + text.slice(m.end)).replace(/^\s+/, m.start === 0 ? "" : " ");
setText(next);
requestAnimationFrame(() => {
const ta = taRef.current;
if (!ta) return;
ta.focus();
const pos = Math.min(m.start, next.length);
ta.setSelectionRange(pos, pos);
});
};
const pick = (index: number) => {
if (!menu) return;
const item = menuItems[index];
if (!item) return;
if (menu.kind === "command") setCommand(item as ComposerCommand);
else setPicked((p) => [...p, item as ComposerContext]);
removeToken(menu);
setMenu(null);
};
const addFiles = (files: FileList | File[]) => {
const list = Array.from(files).map<ComposerAttachment>((f) => ({
id: nextId(),
name: f.name || "pasted-image.png",
size: f.size,
type: f.type,
file: f,
previewUrl: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
}));
if (list.length) setAttachments((a) => [...a, ...list]);
};
const removeAttachment = (id: string) =>
setAttachments((a) => {
const hit = a.find((x) => x.id === id);
if (hit?.previewUrl) URL.revokeObjectURL(hit.previewUrl);
return a.filter((x) => x.id !== id);
});
const canSend = !disabled && !generating && (text.trim().length > 0 || attachments.length > 0 || !!command);
const submit = () => {
if (!canSend) return;
onSubmit?.({ text: text.trim(), command, contexts: picked, attachments, model, tools: activeTools });
lastPrompt.current = text;
setText("");
setCommand(null);
setPicked([]);
setAttachments([]); // ownership of preview URLs passes to the consumer
setMenu(null);
};
const toggleVoice = () => {
if (listening) {
recognizer.current?.stop();
setListening(false);
return;
}
setListening(true);
const w = window as unknown as { SpeechRecognition?: SpeechCtor; webkitSpeechRecognition?: SpeechCtor };
const Ctor = speechRecognition ? (w.SpeechRecognition ?? w.webkitSpeechRecognition) : undefined;
if (!Ctor) return; // visual-only fallback; tap again to stop
try {
const rec = new Ctor();
rec.continuous = true;
rec.interimResults = false;
rec.lang = document.documentElement.lang || "en-US";
rec.onresult = (e) => {
let chunk = "";
for (let i = e.resultIndex; i < e.results.length; i++) if (e.results[i].isFinal) chunk += e.results[i][0].transcript;
if (chunk) setText((t) => (t ? `${t.trimEnd()} ${chunk.trim()}` : chunk.trim()));
};
rec.onend = () => setListening(false);
rec.onerror = () => setListening(false);
recognizer.current = rec;
rec.start();
} catch {
setListening(false);
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
if (menu && menuItems.length) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMenuIndex((i) => (i + 1) % menuItems.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMenuIndex((i) => (i - 1 + menuItems.length) % menuItems.length);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
pick(menuIndex);
return;
}
}
if (e.key === "Escape") {
if (menu) return setMenu(null);
if (generating) return onStop?.();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
return;
}
if (e.key === "Backspace" && text === "" && (e.currentTarget.selectionStart ?? 0) === 0) {
if (picked.length) setPicked((p) => p.slice(0, -1));
else if (command) setCommand(null);
else if (attachments.length) removeAttachment(attachments[attachments.length - 1].id);
}
if (e.key === "ArrowUp" && text === "" && lastPrompt.current) {
e.preventDefault();
setText(lastPrompt.current);
}
};
const currentModel = models.find((m) => m.id === model);
const listId = `${baseId}-menu`;
const optId = (i: number) => `${baseId}-opt-${i}`;
const menuOpen = !!menu && menuItems.length > 0;
const pop = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: { initial: { opacity: 0, y: 6, scale: 0.98 }, animate: { opacity: 1, y: 0, scale: 1 }, exit: { opacity: 0, y: 6, scale: 0.98 } };
return (
<div
className={cn("relative w-full", className)}
onDragEnter={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragDepth.current++;
setDragging(true);
}}
onDragOver={(e) => e.dataTransfer.types.includes("Files") && e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (!dragDepth.current) setDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragging(false);
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}}
>
{/* Slash / mention menu */}
<AnimatePresence>
{menuOpen && (
<motion.div
{...pop}
transition={{ duration: 0.14 }}
className="absolute inset-x-2 bottom-full z-20 mb-2 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-xl sm:right-auto sm:w-80"
>
<p className="flex items-center gap-1.5 border-b px-3 py-2 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
{menu.kind === "command" ? <Slash className="size-3" /> : <AtSign className="size-3" />}
{menu.kind === "command" ? "Commands" : "Add context"}
</p>
<ul id={listId} role="listbox" aria-label={menu.kind === "command" ? "Commands" : "Context"} className="max-h-60 overflow-auto p-1">
{menuItems.map((item, i) => {
const Icon =
menu.kind === "command"
? ((item as ComposerCommand).icon ?? Slash)
: CONTEXT_ICON[(item as ComposerContext).kind ?? "doc"];
return (
<li
key={item.id}
id={optId(i)}
role="option"
aria-selected={i === menuIndex}
onPointerDown={(e) => e.preventDefault()}
onClick={() => pick(i)}
onPointerMove={() => setMenuIndex(i)}
className="flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-sm aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<span className="grid size-7 shrink-0 place-items-center rounded-md border bg-background">
<Icon className="size-3.5" />
</span>
<span className="min-w-0">
<span className="block truncate font-medium">
{menu.kind === "command" ? `/${item.label}` : item.label}
</span>
{item.description && <span className="block truncate text-xs text-muted-foreground">{item.description}</span>}
</span>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
<div
className={cn(
"relative rounded-2xl border bg-card shadow-sm transition focus-within:border-ring/60 focus-within:ring-4 focus-within:ring-ring/15",
dragging && "border-ring ring-4 ring-ring/20",
disabled && "opacity-60",
)}
>
{/* Drop overlay */}
<AnimatePresence>
{dragging && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-2xl border-2 border-dashed border-ring bg-background/85 backdrop-blur-sm"
>
<span className="flex items-center gap-2 text-sm font-medium">
<Upload className="size-4" /> Drop files to attach
</span>
</motion.div>
)}
</AnimatePresence>
{/* Chips */}
<AnimatePresence initial={false}>
{(attachments.length > 0 || picked.length > 0 || command) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<ul aria-label="Attached" className="flex flex-wrap gap-2 px-3 pt-3">
{command && (
<Chip key="cmd" onRemove={() => setCommand(null)} label={`Remove /${command.label}`}>
<Slash className="size-3.5 text-primary" />
<span className="font-medium text-primary">{command.label}</span>
</Chip>
)}
{picked.map((c) => {
const Icon = CONTEXT_ICON[c.kind ?? "doc"];
return (
<Chip key={c.id} onRemove={() => setPicked((p) => p.filter((x) => x.id !== c.id))} label={`Remove ${c.label}`}>
<Icon className="size-3.5 text-muted-foreground" />
<span className="max-w-36 truncate">{c.label}</span>
</Chip>
);
})}
{attachments.map((a) => (
<Chip key={a.id} onRemove={() => removeAttachment(a.id)} label={`Remove ${a.name}`} media={!!a.previewUrl}>
{a.previewUrl ? (
<img src={a.previewUrl} alt="" className="size-9 rounded-md object-cover" />
) : (
<span className="grid size-9 place-items-center rounded-md bg-muted">
<FileText className="size-4 text-muted-foreground" />
</span>
)}
<span className="min-w-0 leading-tight">
<span className="block max-w-32 truncate text-xs font-medium">{a.name}</span>
<span className="block text-[11px] text-muted-foreground">{formatSize(a.size)}</span>
</span>
</Chip>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
<label htmlFor={`${baseId}-ta`} className="sr-only">
Message
</label>
<textarea
id={`${baseId}-ta`}
ref={taRef}
rows={1}
value={text}
disabled={disabled}
placeholder={listening ? "Listening… speak now" : placeholder}
role="combobox"
aria-expanded={menuOpen}
aria-controls={menuOpen ? listId : undefined}
aria-autocomplete="list"
aria-activedescendant={menuOpen ? optId(menuIndex) : undefined}
onChange={(e) => {
setText(e.target.value);
detectMenu(e.target.value, e.target.selectionStart ?? e.target.value.length);
}}
onSelect={(e) => {
const t = e.currentTarget;
if (menu) detectMenu(t.value, t.selectionStart ?? t.value.length);
}}
onBlur={() => setMenu(null)}
onKeyDown={onKeyDown}
onPaste={(e) => {
const files = Array.from(e.clipboardData.files);
if (files.length) {
e.preventDefault();
addFiles(files);
}
}}
className="block w-full resize-none bg-transparent px-4 pt-3.5 pb-2 text-[15px] leading-6 outline-none placeholder:text-muted-foreground"
/>
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 pb-2">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
tabIndex={-1}
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
}}
/>
<IconButton label="Attach files" onClick={() => fileRef.current?.click()} disabled={disabled}>
<Paperclip className="size-4" />
</IconButton>
<IconButton
label="Add context"
className="hidden min-[400px]:grid"
onClick={() => {
const ta = taRef.current;
if (!ta) return;
const pos = ta.selectionStart ?? text.length;
const needsSpace = pos > 0 && !/\s/.test(text[pos - 1]);
const next = `${text.slice(0, pos)}${needsSpace ? " " : ""}@${text.slice(pos)}`;
setText(next);
const caret = pos + (needsSpace ? 2 : 1);
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(caret, caret);
detectMenu(next, caret);
});
}}
>
<AtSign className="size-4" />
</IconButton>
<div className="mx-1 h-5 w-px bg-border" />
<div role="group" aria-label="Tools" className="flex items-center gap-1">
{tools.map((t) => {
const on = activeTools.includes(t.id);
return (
<button
key={t.id}
type="button"
aria-pressed={on}
onClick={() => setActiveTools((a) => (on ? a.filter((x) => x !== t.id) : [...a, t.id]))}
className={cn(
"inline-flex h-8 items-center gap-1.5 rounded-full border px-2 text-xs font-medium transition outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-2.5",
on ? "border-primary/30 bg-primary/10 text-primary" : "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<t.icon className="size-3.5" />
<span className="sr-only sm:not-sr-only">{t.label}</span>
</button>
);
})}
</div>
<div className="ml-auto flex items-center gap-1">
<div ref={modelWrap} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={modelOpen}
onClick={() => setModelOpen((o) => !o)}
onKeyDown={(e) => e.key === "Escape" && setModelOpen(false)}
className="inline-flex h-8 items-center gap-1 rounded-full px-2.5 text-xs font-medium text-muted-foreground transition outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="max-w-24 truncate">{currentModel?.label}</span>
<ChevronDown className={cn("size-3.5 transition", modelOpen && "rotate-180")} />
</button>
<AnimatePresence>
{modelOpen && (
<motion.ul
{...pop}
transition={{ duration: 0.14 }}
role="listbox"
aria-label="Model"
className="absolute right-0 bottom-full z-20 mb-2 w-64 rounded-xl border bg-popover p-1 text-popover-foreground shadow-xl"
>
{models.map((m) => (
<li key={m.id} role="option" aria-selected={m.id === model}>
<button
type="button"
autoFocus={m.id === model}
onClick={() => {
setModel(m.id);
setModelOpen(false);
taRef.current?.focus();
}}
onKeyDown={(e) => {
if (e.key === "Escape") setModelOpen(false);
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const sib = e.key === "ArrowDown" ? e.currentTarget.parentElement?.nextElementSibling : e.currentTarget.parentElement?.previousElementSibling;
(sib?.querySelector("button") as HTMLButtonElement | null)?.focus();
}
}}
className="flex w-full items-start gap-2 rounded-lg px-2.5 py-2 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent"
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 font-medium">
{m.label}
{m.badge && <span className="rounded-full bg-primary/10 px-1.5 text-[10px] font-semibold text-primary">{m.badge}</span>}
</span>
{m.description && <span className="block text-xs text-muted-foreground">{m.description}</span>}
</span>
{m.id === model && <Check className="mt-0.5 size-4 text-primary" />}
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
{canRegenerate && !generating && onRegenerate && (
<IconButton label="Regenerate response" onClick={onRegenerate}>
<RotateCcw className="size-4" />
</IconButton>
)}
<button
type="button"
aria-pressed={listening}
aria-label={listening ? "Stop dictation" : "Dictate"}
onClick={toggleVoice}
disabled={disabled}
className={cn(
"relative grid size-8 place-items-center rounded-full transition outline-none focus-visible:ring-2 focus-visible:ring-ring",
listening ? "bg-rose-500/15 text-rose-600 dark:text-rose-400" : "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{listening ? <VoiceBars reduce={!!reduce} /> : <Mic className="size-4" />}
</button>
{generating ? (
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
className="grid size-8 place-items-center rounded-full bg-foreground text-background transition outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<Square className="size-3 fill-current" />
</button>
) : (
<button
type="button"
onClick={submit}
disabled={!canSend}
aria-label="Send message"
className="grid size-8 place-items-center rounded-full bg-primary text-primary-foreground transition outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:bg-muted disabled:text-muted-foreground"
>
<ArrowUp className="size-4" />
</button>
)}
</div>
</div>
</div>
<p className="mt-2 hidden justify-center gap-3 text-[11px] text-muted-foreground sm:flex">
<span>
<Kbd>Enter</Kbd> send
</span>
<span>
<Kbd>Shift</Kbd>+<Kbd>Enter</Kbd> new line
</span>
<span>
<Kbd>/</Kbd> commands
</span>
<span>
<Kbd>@</Kbd> context
</span>
<span>
<Kbd>Esc</Kbd> stop
</span>
</p>
</div>
);
}
function Chip({ children, onRemove, label, media }: { children: React.ReactNode; onRemove: () => void; label: string; media?: boolean }) {
return (
<motion.li
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className={cn(
"group relative flex items-center gap-2 rounded-lg border bg-background text-sm",
media ? "py-1 pr-2 pl-1" : "h-7 px-2",
!media && "pr-1",
)}
>
{children}
<button
type="button"
aria-label={label}
onClick={onRemove}
className="grid size-5 place-items-center rounded-md text-muted-foreground transition outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</motion.li>
);
}
function IconButton({
children,
label,
onClick,
disabled,
className,
}: {
children: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
className?: string;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
className={cn(
"grid size-8 place-items-center rounded-full text-muted-foreground transition outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50",
className,
)}
>
{children}
</button>
);
}
function VoiceBars({ reduce }: { reduce: boolean }) {
return (
<span className="flex h-4 items-center gap-[2px]" aria-hidden>
{[0, 1, 2, 3].map((i) => (
<motion.span
key={i}
className="w-[3px] rounded-full bg-current"
animate={reduce ? { height: 8 } : { height: [4, 14, 6, 12, 4] }}
transition={{ duration: 0.9, repeat: Infinity, delay: i * 0.12, ease: "easeInOut" }}
/>
))}
</span>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return <kbd className="rounded border bg-muted px-1 font-sans text-[10px] font-medium text-foreground/80">{children}</kbd>;
}