TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { ArrowUp, Camera, ChevronDown, ChevronUp, ClipboardPaste, FileText, ImagePlus, Library, Mic, MicOff, Plus, Square } from "lucide-react";4import type { PolyModel } from "@/lib/client/types";5import { Button } from "@/components/ui/button";6import { Tooltip } from "@/components/ui/tooltip";7import { Kbd } from "@/components/ui/misc";8import { toast } from "@/components/ui/toast";9import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";10import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";11import { FileLibraryPicker, type PickedAttachment } from "@/components/library/file-library-picker";12import { useIsCoarsePointer, useIsMobile } from "@/lib/client/hooks";13import { AttachmentChip } from "./message";14import { useSpeechDictation } from "./use-speech";15import { cn } from "@/lib/utils";1617export interface PendingAttachment {18 id: string;19 kind: string;20 name: string;21 mimeType: string;22 sizeBytes: number;23 width?: number | null;24 height?: number | null;25}2627/** Extra entries for the `+` menu (chat adds tools, web search, structured output, system prompt, temporary chat). */28export interface ComposerAction {29 key: string;30 label: React.ReactNode;31 icon?: React.ReactNode;32 hint?: React.ReactNode;33 onSelect: () => void;34 selected?: boolean;35 disabled?: boolean;36 destructive?: boolean;37}3839export interface ComposerHandle {40 focus(): void;41 /** Open the native file picker (`kind` narrows the accepted types). */42 openFilePicker(kind?: "image" | "document" | "camera"): void;43 /** Insert text at the caret (or append) and focus. */44 insert(text: string): void;45}4647interface Props {48 model?: PolyModel;49 disabled?: boolean;50 busy?: boolean;51 enterToSend?: boolean;52 placeholder?: string;53 attachments: PendingAttachment[];54 onAttachmentsChange: (a: PendingAttachment[]) => void;55 onSend: (text: string) => void;56 onStop: () => void;57 leftSlot?: React.ReactNode;58 rightSlot?: React.ReactNode;59 /** Row rendered above the input box (model pill, context indicator…). */60 topSlot?: React.ReactNode;61 /** Row rendered below the input box (hints, banners). */62 bottomSlot?: React.ReactNode;63 autoFocus?: boolean;64 value?: string;65 onValueChange?: (v: string) => void;66 /** Extra `+` menu entries, appended after the attachment actions. */67 extraActions?: (ComposerAction | "separator")[];68 /** Hide the microphone even when the browser supports dictation. */69 voice?: boolean;70 /** Maximum visible lines before the textarea scrolls (default 6). */71 maxLines?: number;72 /** Allow uploads even without a model (e.g. AUTO routing). */73 allowAttachWithoutModel?: boolean;74 /** Which library the "From library" picker should target. */75 projectId?: string | null;76}7778const LINE_PX = 24;79const PAD_Y = 20; // pt-2.5 + pb-2.58081const 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";82const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";8384export const Composer = React.forwardRef<ComposerHandle, Props>(function Composer(85 { model, disabled, busy, enterToSend = true, placeholder, attachments, onAttachmentsChange, onSend, onStop, leftSlot, rightSlot, topSlot, bottomSlot, autoFocus, value, onValueChange, extraActions, voice = true, maxLines = 6, allowAttachWithoutModel, projectId },86 ref,87) {88 const [inner, setInner] = React.useState("");89 const text = value ?? inner;90 const setText = onValueChange ?? setInner;91 const textRef = React.useRef(text);92 React.useEffect(() => {93 textRef.current = text;94 }, [text]);95 const taRef = React.useRef<HTMLTextAreaElement>(null);96 const fileRef = React.useRef<HTMLInputElement>(null);97 const cameraRef = React.useRef<HTMLInputElement>(null);98 const [fileAccept, setFileAccept] = React.useState<string>("");99 const [uploading, setUploading] = React.useState(0);100 const [dragging, setDragging] = React.useState(false);101 const [collapsed, setCollapsed] = React.useState(false);102 const [multiline, setMultiline] = React.useState(false);103 const [menuOpen, setMenuOpen] = React.useState(false);104 const [libraryOpen, setLibraryOpen] = React.useState(false);105 const isMobile = useIsMobile();106 const coarse = useIsCoarsePointer();107108 const canAttach = Boolean(model) || Boolean(allowAttachWithoutModel);109 const canImages = model ? Boolean(model.capabilities.vision) : Boolean(allowAttachWithoutModel);110 const canPdf = model ? Boolean(model.capabilities.files) : Boolean(allowAttachWithoutModel);111112 // --- auto-grow 1 → maxLines, then scroll -------------------------------------------------------113 React.useLayoutEffect(() => {114 const el = taRef.current;115 if (!el) return;116 el.style.height = "0px";117 const natural = el.scrollHeight;118 const lines = Math.max(1, Math.round((natural - PAD_Y) / LINE_PX));119 const maxH = maxLines * LINE_PX + PAD_Y;120 const target = collapsed ? LINE_PX + PAD_Y : Math.min(maxH, Math.max(LINE_PX + PAD_Y, natural));121 el.style.height = `${target}px`;122 el.style.overflowY = natural > target ? "auto" : "hidden";123 setMultiline(lines > 1);124 if (lines <= 1 && collapsed) setCollapsed(false);125 }, [text, maxLines, collapsed]);126127 React.useEffect(() => {128 if (autoFocus && !coarse) taRef.current?.focus();129 }, [autoFocus, coarse]);130131 // --- imperative handle ----------------------------------------------------------------------------132 const insertAtCaret = React.useCallback(133 (snippet: string) => {134 const el = taRef.current;135 const cur = textRef.current;136 if (!el) {137 setText(cur ? `${cur}\n${snippet}` : snippet);138 return;139 }140 const start = el.selectionStart ?? cur.length;141 const end = el.selectionEnd ?? cur.length;142 const before = cur.slice(0, start);143 const after = cur.slice(end);144 const next = `${before}${before && !/\s$/.test(before) ? "\n" : ""}${snippet}${after}`;145 setText(next);146 requestAnimationFrame(() => {147 el.focus();148 const pos = next.length - after.length;149 el.setSelectionRange(pos, pos);150 });151 },152 [setText],153 );154155 const openFilePicker = React.useCallback(156 (kind?: "image" | "document" | "camera") => {157 if (kind === "camera") {158 cameraRef.current?.click();159 return;160 }161 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(",");162 setFileAccept(accept);163 // Let React commit the new `accept` before opening the dialog.164 requestAnimationFrame(() => fileRef.current?.click());165 },166 [canImages, canPdf],167 );168169 React.useImperativeHandle(ref, () => ({ focus: () => taRef.current?.focus(), openFilePicker, insert: insertAtCaret }), [openFilePicker, insertAtCaret]);170171 // --- voice dictation ------------------------------------------------------------------------------172 const baseRef = React.useRef("");173 const speech = useSpeechDictation((final, interim) => {174 const base = baseRef.current;175 const joiner = base && !/\s$/.test(base) ? " " : "";176 setText(`${base}${joiner}${final}${final && interim ? " " : ""}${interim}`);177 });178 const toggleVoice = () => {179 if (!speech.listening) baseRef.current = textRef.current;180 speech.toggle();181 };182 React.useEffect(() => {183 if (speech.error) toast.warning("Dictation unavailable", speech.error);184 }, [speech.error]);185186 // --- submit ---------------------------------------------------------------------------------------187 const submit = () => {188 if (busy) return;189 const t = text.trim();190 if (!t && attachments.length === 0) return;191 if (speech.listening) speech.stop();192 onSend(t);193 setText("");194 setCollapsed(false);195 };196197 // --- uploads --------------------------------------------------------------------------------------198 const upload = async (files: FileList | File[]) => {199 const list = Array.from(files).slice(0, 10 - attachments.length);200 if (!list.length) return;201 let current = attachments;202 for (const f of list) {203 const isImage = f.type.startsWith("image/");204 if (isImage && !canImages) {205 toast.warning("This model has no vision", `${model?.displayName ?? "The selected model"} cannot read images. Pick a vision-capable model.`);206 continue;207 }208 if (f.type === "application/pdf" && !canPdf) {209 toast.warning("PDF not supported by this model", "Pick a model with file input (e.g. Claude, GPT-5.x, Gemini).");210 continue;211 }212 if (f.size > 10 * 1024 * 1024) {213 toast.error("File too large", "Max 10 MB per file.");214 continue;215 }216 setUploading((n) => n + 1);217 try {218 const fd = new FormData();219 fd.append("file", f, f.name || (isImage ? "photo.jpg" : "file"));220 const res = await fetch("/api/attachments", { method: "POST", body: fd });221 const data = await res.json();222 if (!res.ok) throw new Error(data?.error?.message ?? "Upload failed");223 current = [...current, data.attachment];224 onAttachmentsChange(current);225 } catch (e) {226 toast.error("Upload failed", (e as Error).message);227 } finally {228 setUploading((n) => n - 1);229 }230 }231 };232233 const onPaste = (e: React.ClipboardEvent) => {234 const items = Array.from(e.clipboardData.items).filter((i) => i.kind === "file");235 if (!items.length) return;236 const files = items.map((i) => i.getAsFile()).filter((f): f is File => Boolean(f));237 if (files.length) {238 e.preventDefault();239 void upload(files);240 }241 };242243 const pasteFromClipboard = async () => {244 try {245 const nav = navigator as Navigator & { clipboard: Clipboard & { read?: () => Promise<ClipboardItem[]> } };246 if (nav.clipboard.read) {247 const items = await nav.clipboard.read();248 const files: File[] = [];249 let textFound = "";250 for (const item of items) {251 const imgType = item.types.find((t) => t.startsWith("image/"));252 if (imgType) {253 const blob = await item.getType(imgType);254 files.push(new File([blob], `pasted.${imgType.split("/")[1] ?? "png"}`, { type: imgType }));255 } else if (item.types.includes("text/plain")) {256 textFound += await (await item.getType("text/plain")).text();257 }258 }259 if (files.length) await upload(files);260 if (textFound) insertAtCaret(textFound);261 if (!files.length && !textFound) toast.info("Clipboard is empty");262 return;263 }264 const t = await navigator.clipboard.readText();265 if (t) insertAtCaret(t);266 else toast.info("Clipboard is empty");267 } catch {268 toast.warning("Clipboard access denied", "Use ⌘V / Ctrl+V inside the message box instead.");269 }270 };271272 // --- `+` menu -------------------------------------------------------------------------------------273 const items: (ComposerAction | "separator")[] = [274 ...(canAttach275 ? ([276 ...(canImages ? [{ key: "image", label: "Upload image", icon: <ImagePlus />, onSelect: () => openFilePicker("image") }] : []),277 { key: "document", label: "Upload document", icon: <FileText />, hint: canPdf ? "PDF, text, code" : "Text, code", onSelect: () => openFilePicker("document") },278 ...(canImages && coarse ? [{ key: "camera", label: "Camera", icon: <Camera />, onSelect: () => openFilePicker("camera") }] : []),279 { key: "paste", label: "Paste content", icon: <ClipboardPaste />, onSelect: () => void pasteFromClipboard() },280 { key: "library", label: "From library", icon: <Library />, onSelect: () => setLibraryOpen(true) },281 ] as ComposerAction[])282 : []),283 ...(extraActions?.length ? (canAttach ? (["separator", ...extraActions] as (ComposerAction | "separator")[]) : extraActions) : []),284 ];285 const activeCount = (extraActions ?? []).filter((i) => i !== "separator" && i.selected).length;286287 const hasContent = Boolean(text.trim()) || attachments.length > 0;288 const showVoice = voice && speech.supported && !disabled;289290 return (291 <div className="w-full">292 {topSlot}293 <div294 className={cn("relative rounded-[22px] border bg-bg-elevated shadow-sm transition-[border-color,box-shadow]", dragging ? "border-accent shadow-glow" : "border-border focus-within:border-border-strong focus-within:shadow-md", speech.listening && "border-accent/60")}295 onDragOver={(e) => {296 if (!canAttach) return;297 e.preventDefault();298 setDragging(true);299 }}300 onDragLeave={() => setDragging(false)}301 onDrop={(e) => {302 e.preventDefault();303 setDragging(false);304 if (canAttach && e.dataTransfer.files.length) void upload(e.dataTransfer.files);305 }}306 >307 {attachments.length || uploading ? (308 <div className="flex flex-wrap gap-1.5 px-3 pt-3">309 {attachments.map((a) => (310 <AttachmentChip key={a.id} a={a} onRemove={() => onAttachmentsChange(attachments.filter((x) => x.id !== a.id))} />311 ))}312 {uploading ? <div className="inline-flex h-9 items-center gap-2 rounded-lg border border-dashed border-border px-3 text-[12px] text-fg-muted">Uploading…</div> : null}313 </div>314 ) : null}315316 <div className="flex items-end gap-1 px-1.5 py-1.5">317 {/* + menu */}318 <input ref={fileRef} type="file" multiple hidden accept={fileAccept || undefined} onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />319 <input ref={cameraRef} type="file" hidden accept="image/*" capture="environment" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />320 {/* On phones the caller's extra controls move to a toolbar row below so the text field keeps its width. */}321 {!isMobile ? leftSlot : null}322 <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} />323324 {/* textarea */}325 <div className="relative min-w-0 flex-1">326 <textarea327 ref={taRef}328 value={text}329 onChange={(e) => setText(e.target.value)}330 onPaste={onPaste}331 onKeyDown={(e) => {332 if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && (enterToSend || e.metaKey || e.ctrlKey)) {333 e.preventDefault();334 submit();335 } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {336 e.preventDefault();337 submit();338 } else if (e.key === "Escape" && busy) {339 onStop();340 }341 }}342 placeholder={placeholder ?? (speech.listening ? "Listening…" : "Ask anything…")}343 disabled={disabled}344 rows={1}345 className={cn("block w-full resize-none bg-transparent px-2 pb-2.5 pt-2.5 text-[16px] leading-6 outline-none placeholder:text-fg-subtle disabled:opacity-60 scrollbar-thin sm:text-[15px]", multiline && "pr-8")}346 aria-label="Message"347 enterKeyHint={enterToSend ? "send" : "enter"}348 autoCapitalize="sentences"349 />350 {multiline ? (351 <button type="button" onClick={() => setCollapsed((c) => !c)} className="tap absolute right-0 top-1 rounded-md p-1 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={collapsed ? "Expand message" : "Collapse message"}>352 {collapsed ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}353 </button>354 ) : null}355 </div>356357 {rightSlot}358 {/* voice */}359 {showVoice ? (360 <Tooltip content={speech.listening ? "Stop dictation" : "Dictate"}>361 <Button type="button" variant="ghost" size="icon" className={cn("tap size-9 shrink-0 rounded-full", speech.listening && "bg-accent-soft text-accent animate-pulse-soft")} onClick={toggleVoice} aria-label={speech.listening ? "Stop dictation" : "Start dictation"} aria-pressed={speech.listening}>362 {speech.listening ? <MicOff /> : <Mic />}363 </Button>364 </Tooltip>365 ) : null}366 {/* send / stop */}367 {busy ? (368 <Tooltip content="Stop generation (Esc)">369 <Button type="button" size="icon" variant="secondary" className="tap size-9 shrink-0 rounded-full" onClick={onStop} aria-label="Stop generation">370 <Square className="size-3.5 fill-current" />371 </Button>372 </Tooltip>373 ) : (374 <Button type="button" size="icon" variant={hasContent ? "accent" : "secondary"} className="tap size-9 shrink-0 rounded-full" onClick={submit} disabled={disabled || !hasContent || uploading > 0} aria-label="Send">375 <ArrowUp />376 </Button>377 )}378 </div>379 {isMobile && leftSlot ? <div className="-mt-1 flex items-center gap-1 overflow-x-auto px-2 pb-1.5 scrollbar-none">{leftSlot}</div> : null}380 </div>381 {bottomSlot ?? (382 <p className="mt-1.5 hidden items-center justify-center gap-1 text-center text-[11px] text-fg-subtle sm:flex">383 {enterToSend ? (384 <>385 <Kbd>↵</Kbd> send · <Kbd>⇧↵</Kbd> newline386 </>387 ) : (388 <>389 <Kbd>⌘↵</Kbd> send390 </>391 )}392 </p>393 )}394395 <FileLibraryPicker open={libraryOpen} onOpenChange={setLibraryOpen} projectId={projectId} onPick={(files: PickedAttachment[]) => onAttachmentsChange([...attachments, ...files].slice(0, 10))} />396 </div>397 );398});399400function PlusMenu({ items, open, onOpenChange, disabled, activeCount, isMobile }: { items: (ComposerAction | "separator")[]; open: boolean; onOpenChange: (o: boolean) => void; disabled?: boolean; activeCount: number; isMobile: boolean }) {401 const trigger = (402 <Button type="button" variant="ghost" size="icon" className="tap relative size-9 shrink-0 rounded-full" disabled={disabled || items.length === 0} aria-label="Add attachment or option" aria-haspopup="menu" onClick={isMobile ? () => onOpenChange(true) : undefined}>403 <Plus className={cn("transition-transform", open && "rotate-45")} />404 {activeCount > 0 ? <span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-fg">{activeCount}</span> : null}405 </Button>406 );407 if (isMobile) {408 const sheetItems: (ActionSheetItem | "separator")[] = items.map((it) => (it === "separator" ? "separator" : { key: it.key, label: it.label, icon: it.icon, hint: it.hint, onSelect: it.onSelect, selected: it.selected, disabled: it.disabled, destructive: it.destructive }));409 return (410 <>411 {trigger}412 <ActionSheet open={open} onOpenChange={onOpenChange} items={sheetItems} />413 </>414 );415 }416 return (417 <DropdownMenu open={open} onOpenChange={onOpenChange}>418 <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>419 <DropdownMenuContent align="start" side="top" sideOffset={8} className="min-w-[240px]">420 {items.map((it, i) =>421 it === "separator" ? (422 <DropdownMenuSeparator key={`sep-${i}`} />423 ) : (424 <DropdownMenuItem key={it.key} disabled={it.disabled} destructive={it.destructive} onSelect={() => setTimeout(it.onSelect, 10)} className="gap-2.5">425 <span className="text-fg-muted [&_svg]:size-4">{it.icon}</span>426 <span className="min-w-0 flex-1 truncate">{it.label}</span>427 {it.hint ? <span className="ml-2 text-[11px] text-fg-subtle">{it.hint}</span> : null}428 {it.selected ? <span className="ml-2 text-accent">✓</span> : null}429 </DropdownMenuItem>430 ),431 )}432 </DropdownMenuContent>433 </DropdownMenu>434 );435}436