Composer An input that grows with its content to a ceiling and then scrolls, with a finite control row inside it. The send button becomes stop during generation, so the thing that starts a response is also the thing that halts it. npx shadcn@latest add textarea button npm i lucide-react Tokens this needs: --background, --foreground, --primary, --primary-foreground, --accent, --accent-foreground, --muted-foreground, --border ──────────────────────────────────────────────────────────────────────── // Composer.tsx ──────────────────────────────────────────────────────────────────────── import { useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type KeyboardEvent } from "react"; import { ChevronDown, Mic, Square, ArrowUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; /** * Grows with its content to a ceiling, then scrolls inside. Unbounded growth * pushes the answer off the screen while it is being replied to, which is the * one thing a composer must not do. */ const MAX_HEIGHT = 160; /** * Above this, a paste stops being text and becomes an attachment. A wall of * pasted text in the box is unreadable and unscrollable at the ceiling, and it * is almost always a document rather than a message. */ const PASTE_AS_ATTACHMENT = 2000; type Props = { /** Keys the draft in sessionStorage, so navigating away and back keeps it. */ draftKey: string; /** What the box holds when there is no saved draft. */ defaultDraft?: string; /** True while a response streams. The send slot becomes stop. */ streaming: boolean; onSend: (text: string) => void; onStop: () => void; onAttach: (file: File | string) => void; model: string; onPickModel: () => void; onVoice: () => void; }; export function Composer({ draftKey, defaultDraft = "", streaming, onSend, onStop, onAttach, model, onPickModel, onVoice, }: Props) { // Guarded, because this also renders on the server, where there is no storage. const [draft, setDraft] = useState( () => (typeof window === "undefined" ? null : sessionStorage.getItem(draftKey)) ?? defaultDraft, ); const ref = useRef(null); const fileRef = useRef(null); useEffect(() => { sessionStorage.setItem(draftKey, draft); }, [draftKey, draft]); // Measure, then set. Resetting to auto first lets the box shrink when a line // is deleted; without it scrollHeight only ever reports the larger size. useLayoutEffect(() => { const el = ref.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`; }, [draft]); const send = () => { const text = draft.trim(); if (!text || streaming) return; onSend(text); setDraft(""); }; // Enter sends, Shift+Enter breaks the line. There is no right answer here, // but an accidental send burns a real generation, so the modifier guards the // cheaper mistake: a newline you did not want costs a keystroke to fix. const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(); } }; const onPaste = (e: ClipboardEvent) => { const file = e.clipboardData.files[0]; const text = e.clipboardData.getData("text/plain"); if (file) { e.preventDefault(); onAttach(file); return; } if (text.length > PASTE_AS_ATTACHMENT) { e.preventDefault(); onAttach(text); } }; return (