Fazekit

Code

"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>;
}

More in AI

View all →