"use client"; import * as React from "react"; import { ArrowUp, Camera, ChevronDown, ChevronUp, ClipboardPaste, FileText, ImagePlus, Library, Mic, MicOff, Plus, Square } from "lucide-react"; import type { PolyModel } from "@/lib/client/types"; import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/ui/tooltip"; import { Kbd } from "@/components/ui/misc"; import { toast } from "@/components/ui/toast"; import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { FileLibraryPicker, type PickedAttachment } from "@/components/library/file-library-picker"; import { useIsCoarsePointer, useIsMobile } from "@/lib/client/hooks"; import { AttachmentChip } from "./message"; import { useSpeechDictation } from "./use-speech"; import { cn } from "@/lib/utils"; export interface PendingAttachment { id: string; kind: string; name: string; mimeType: string; sizeBytes: number; width?: number | null; height?: number | null; } /** Extra entries for the `+` menu (chat adds tools, web search, structured output, system prompt, temporary chat). */ export interface ComposerAction { key: string; label: React.ReactNode; icon?: React.ReactNode; hint?: React.ReactNode; onSelect: () => void; selected?: boolean; disabled?: boolean; destructive?: boolean; } export interface ComposerHandle { focus(): void; /** Open the native file picker (`kind` narrows the accepted types). */ openFilePicker(kind?: "image" | "document" | "camera"): void; /** Insert text at the caret (or append) and focus. */ insert(text: string): void; } interface Props { model?: PolyModel; disabled?: boolean; busy?: boolean; enterToSend?: boolean; placeholder?: string; attachments: PendingAttachment[]; onAttachmentsChange: (a: PendingAttachment[]) => void; onSend: (text: string) => void; onStop: () => void; leftSlot?: React.ReactNode; rightSlot?: React.ReactNode; /** Row rendered above the input box (model pill, context indicator…). */ topSlot?: React.ReactNode; /** Row rendered below the input box (hints, banners). */ bottomSlot?: React.ReactNode; autoFocus?: boolean; value?: string; onValueChange?: (v: string) => void; /** Extra `+` menu entries, appended after the attachment actions. */ extraActions?: (ComposerAction | "separator")[]; /** Hide the microphone even when the browser supports dictation. */ voice?: boolean; /** Maximum visible lines before the textarea scrolls (default 6). */ maxLines?: number; /** Allow uploads even without a model (e.g. AUTO routing). */ allowAttachWithoutModel?: boolean; /** Which library the "From library" picker should target. */ projectId?: string | null; } const LINE_PX = 24; const PAD_Y = 20; // pt-2.5 + pb-2.5 const TEXT_ACCEPT = ".txt,.md,.csv,.json,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.kt,.swift,.rb,.php,.c,.cpp,.h,.cs,.sh,.yaml,.yml,.toml,.sql,.html,.css,.xml"; const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif"; export const Composer = React.forwardRef(function Composer( { model, disabled, busy, enterToSend = true, placeholder, attachments, onAttachmentsChange, onSend, onStop, leftSlot, rightSlot, topSlot, bottomSlot, autoFocus, value, onValueChange, extraActions, voice = true, maxLines = 6, allowAttachWithoutModel, projectId }, ref, ) { const [inner, setInner] = React.useState(""); const text = value ?? inner; const setText = onValueChange ?? setInner; const textRef = React.useRef(text); React.useEffect(() => { textRef.current = text; }, [text]); const taRef = React.useRef(null); const fileRef = React.useRef(null); const cameraRef = React.useRef(null); const [fileAccept, setFileAccept] = React.useState(""); const [uploading, setUploading] = React.useState(0); const [dragging, setDragging] = React.useState(false); const [collapsed, setCollapsed] = React.useState(false); const [multiline, setMultiline] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false); const [libraryOpen, setLibraryOpen] = React.useState(false); const isMobile = useIsMobile(); const coarse = useIsCoarsePointer(); const canAttach = Boolean(model) || Boolean(allowAttachWithoutModel); const canImages = model ? Boolean(model.capabilities.vision) : Boolean(allowAttachWithoutModel); const canPdf = model ? Boolean(model.capabilities.files) : Boolean(allowAttachWithoutModel); // --- auto-grow 1 → maxLines, then scroll ------------------------------------------------------- React.useLayoutEffect(() => { const el = taRef.current; if (!el) return; el.style.height = "0px"; const natural = el.scrollHeight; const lines = Math.max(1, Math.round((natural - PAD_Y) / LINE_PX)); const maxH = maxLines * LINE_PX + PAD_Y; const target = collapsed ? LINE_PX + PAD_Y : Math.min(maxH, Math.max(LINE_PX + PAD_Y, natural)); el.style.height = `${target}px`; el.style.overflowY = natural > target ? "auto" : "hidden"; setMultiline(lines > 1); if (lines <= 1 && collapsed) setCollapsed(false); }, [text, maxLines, collapsed]); React.useEffect(() => { if (autoFocus && !coarse) taRef.current?.focus(); }, [autoFocus, coarse]); // --- imperative handle ---------------------------------------------------------------------------- const insertAtCaret = React.useCallback( (snippet: string) => { const el = taRef.current; const cur = textRef.current; if (!el) { setText(cur ? `${cur}\n${snippet}` : snippet); return; } const start = el.selectionStart ?? cur.length; const end = el.selectionEnd ?? cur.length; const before = cur.slice(0, start); const after = cur.slice(end); const next = `${before}${before && !/\s$/.test(before) ? "\n" : ""}${snippet}${after}`; setText(next); requestAnimationFrame(() => { el.focus(); const pos = next.length - after.length; el.setSelectionRange(pos, pos); }); }, [setText], ); const openFilePicker = React.useCallback( (kind?: "image" | "document" | "camera") => { if (kind === "camera") { cameraRef.current?.click(); return; } const accept = kind === "image" ? IMAGE_ACCEPT : kind === "document" ? [canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(",") : [canImages ? IMAGE_ACCEPT : "", canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(","); setFileAccept(accept); // Let React commit the new `accept` before opening the dialog. requestAnimationFrame(() => fileRef.current?.click()); }, [canImages, canPdf], ); React.useImperativeHandle(ref, () => ({ focus: () => taRef.current?.focus(), openFilePicker, insert: insertAtCaret }), [openFilePicker, insertAtCaret]); // --- voice dictation ------------------------------------------------------------------------------ const baseRef = React.useRef(""); const speech = useSpeechDictation((final, interim) => { const base = baseRef.current; const joiner = base && !/\s$/.test(base) ? " " : ""; setText(`${base}${joiner}${final}${final && interim ? " " : ""}${interim}`); }); const toggleVoice = () => { if (!speech.listening) baseRef.current = textRef.current; speech.toggle(); }; React.useEffect(() => { if (speech.error) toast.warning("Dictation unavailable", speech.error); }, [speech.error]); // --- submit --------------------------------------------------------------------------------------- const submit = () => { if (busy) return; const t = text.trim(); if (!t && attachments.length === 0) return; if (speech.listening) speech.stop(); onSend(t); setText(""); setCollapsed(false); }; // --- uploads -------------------------------------------------------------------------------------- const upload = async (files: FileList | File[]) => { const list = Array.from(files).slice(0, 10 - attachments.length); if (!list.length) return; let current = attachments; for (const f of list) { const isImage = f.type.startsWith("image/"); if (isImage && !canImages) { toast.warning("This model has no vision", `${model?.displayName ?? "The selected model"} cannot read images. Pick a vision-capable model.`); continue; } if (f.type === "application/pdf" && !canPdf) { toast.warning("PDF not supported by this model", "Pick a model with file input (e.g. Claude, GPT-5.x, Gemini)."); continue; } if (f.size > 10 * 1024 * 1024) { toast.error("File too large", "Max 10 MB per file."); continue; } setUploading((n) => n + 1); try { const fd = new FormData(); fd.append("file", f, f.name || (isImage ? "photo.jpg" : "file")); const res = await fetch("/api/attachments", { method: "POST", body: fd }); const data = await res.json(); if (!res.ok) throw new Error(data?.error?.message ?? "Upload failed"); current = [...current, data.attachment]; onAttachmentsChange(current); } catch (e) { toast.error("Upload failed", (e as Error).message); } finally { setUploading((n) => n - 1); } } }; const onPaste = (e: React.ClipboardEvent) => { const items = Array.from(e.clipboardData.items).filter((i) => i.kind === "file"); if (!items.length) return; const files = items.map((i) => i.getAsFile()).filter((f): f is File => Boolean(f)); if (files.length) { e.preventDefault(); void upload(files); } }; const pasteFromClipboard = async () => { try { const nav = navigator as Navigator & { clipboard: Clipboard & { read?: () => Promise } }; if (nav.clipboard.read) { const items = await nav.clipboard.read(); const files: File[] = []; let textFound = ""; for (const item of items) { const imgType = item.types.find((t) => t.startsWith("image/")); if (imgType) { const blob = await item.getType(imgType); files.push(new File([blob], `pasted.${imgType.split("/")[1] ?? "png"}`, { type: imgType })); } else if (item.types.includes("text/plain")) { textFound += await (await item.getType("text/plain")).text(); } } if (files.length) await upload(files); if (textFound) insertAtCaret(textFound); if (!files.length && !textFound) toast.info("Clipboard is empty"); return; } const t = await navigator.clipboard.readText(); if (t) insertAtCaret(t); else toast.info("Clipboard is empty"); } catch { toast.warning("Clipboard access denied", "Use ⌘V / Ctrl+V inside the message box instead."); } }; // --- `+` menu ------------------------------------------------------------------------------------- const items: (ComposerAction | "separator")[] = [ ...(canAttach ? ([ ...(canImages ? [{ key: "image", label: "Upload image", icon: , onSelect: () => openFilePicker("image") }] : []), { key: "document", label: "Upload document", icon: , hint: canPdf ? "PDF, text, code" : "Text, code", onSelect: () => openFilePicker("document") }, ...(canImages && coarse ? [{ key: "camera", label: "Camera", icon: , onSelect: () => openFilePicker("camera") }] : []), { key: "paste", label: "Paste content", icon: , onSelect: () => void pasteFromClipboard() }, { key: "library", label: "From library", icon: , onSelect: () => setLibraryOpen(true) }, ] as ComposerAction[]) : []), ...(extraActions?.length ? (canAttach ? (["separator", ...extraActions] as (ComposerAction | "separator")[]) : extraActions) : []), ]; const activeCount = (extraActions ?? []).filter((i) => i !== "separator" && i.selected).length; const hasContent = Boolean(text.trim()) || attachments.length > 0; const showVoice = voice && speech.supported && !disabled; return (
{topSlot}
{ if (!canAttach) return; e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); if (canAttach && e.dataTransfer.files.length) void upload(e.dataTransfer.files); }} > {attachments.length || uploading ? (
{attachments.map((a) => ( onAttachmentsChange(attachments.filter((x) => x.id !== a.id))} /> ))} {uploading ?
Uploading…
: null}
) : null}
{/* + menu */} { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} /> { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} /> {/* On phones the caller's extra controls move to a toolbar row below so the text field keeps its width. */} {!isMobile ? leftSlot : null} {/* textarea */}