import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Activity, ArrowUp, BookOpen, Check, ChevronDown, CircleHelp, History, ImageIcon, Loader2, Plus, RotateCw, Sparkles, Square, SquarePen, Undo2, X, type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useAttachedImages, type AttachedImage, type AttachmentError, MAX_IMAGES_PER_MESSAGE, } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage, SendOptions } from "@/hooks/useNanobotStream"; import type { SlashCommand } from "@/lib/types"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is * deliberately excluded to avoid an embedded-script XSS surface. */ const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } interface ThreadComposerProps { onSend: (content: string, images?: SendImage[], options?: SendOptions) => void; disabled?: boolean; placeholder?: string; isStreaming?: boolean; modelLabel?: string | null; variant?: "thread" | "hero"; slashCommands?: SlashCommand[]; imageMode?: boolean; onImageModeChange?: (enabled: boolean) => void; onStop?: () => void; } const COMMAND_ICONS: Record = { activity: Activity, "book-open": BookOpen, "circle-help": CircleHelp, history: History, "rotate-cw": RotateCw, sparkles: Sparkles, square: Square, "square-pen": SquarePen, "undo-2": Undo2, }; type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9"; const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"]; function slashCommandI18nKey(command: string): string { return command.replace(/^\//, "").replace(/-/g, "_"); } function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) { if (!(target instanceof Element) || deltaY === 0) return; let el: HTMLElement | null = target.parentElement; while (el) { const style = window.getComputedStyle(el); const canScroll = /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight; if (canScroll) { el.scrollTop += deltaY; return; } el = el.parentElement; } } export function ThreadComposer({ onSend, disabled, placeholder, isStreaming = false, modelLabel = null, variant = "thread", slashCommands = [], imageMode: controlledImageMode, onImageModeChange, onStop, }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false); const [imageAspectRatio, setImageAspectRatio] = useState("auto"); const [aspectMenuOpen, setAspectMenuOpen] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); const aspectControlRef = useRef(null); const chipRefs = useRef(new Map()); const isHero = variant === "hero"; const imageMode = controlledImageMode ?? uncontrolledImageMode; const setImageMode = useCallback( (enabled: boolean) => { if (controlledImageMode === undefined) { setUncontrolledImageMode(enabled); } onImageModeChange?.(enabled); }, [controlledImageMode, onImageModeChange], ); const resolvedPlaceholder = isStreaming ? t("thread.composer.placeholderStreaming") : imageMode ? t("thread.composer.imageMode.placeholder") : placeholder ?? t("thread.composer.placeholderThread"); const { images, enqueue, remove, clear, encoding, full } = useAttachedImages(); const formatRejection = useCallback( (reason: AttachmentError): string => { const key = `thread.composer.imageRejected.${reason}`; return t(key, { max: MAX_IMAGES_PER_MESSAGE }); }, [t], ); const addFiles = useCallback( (files: File[]) => { if (files.length === 0) return; const { rejected } = enqueue(files); if (rejected.length > 0) { setInlineError(formatRejection(rejected[0].reason)); } else { setInlineError(null); } }, [enqueue, formatRejection], ); const { isDragging, onPaste, onDragEnter, onDragOver, onDragLeave, onDrop, } = useClipboardAndDrop(addFiles); useEffect(() => { if (disabled) return; const el = textareaRef.current; if (!el) return; const id = requestAnimationFrame(() => el.focus()); return () => cancelAnimationFrame(id); }, [disabled]); const readyImages = useMemo( () => images.filter((img): img is AttachedImage & { dataUrl: string } => img.status === "ready" && typeof img.dataUrl === "string", ), [images], ); const hasErrors = images.some((img) => img.status === "error"); const canSend = !disabled && !encoding && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); const slashQuery = useMemo(() => { if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; const commandToken = value.slice(1); if (/\s/.test(commandToken)) return null; return commandToken.toLowerCase(); }, [disabled, slashMenuDismissed, value]); const filteredSlashCommands = useMemo(() => { if (slashQuery === null) return []; return slashCommands .filter((command) => { const haystack = [ command.command, command.title, command.description, command.argHint ?? "", t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, { defaultValue: "", }), t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, { defaultValue: "", }), ].join(" ").toLowerCase(); return haystack.includes(slashQuery); }) .slice(0, 8); }, [slashCommands, slashQuery, t]); const showSlashMenu = filteredSlashCommands.length > 0; useEffect(() => { setSelectedCommandIndex(0); }, [slashQuery]); useEffect(() => { if (selectedCommandIndex >= filteredSlashCommands.length) { setSelectedCommandIndex(0); } }, [filteredSlashCommands.length, selectedCommandIndex]); useEffect(() => { if (!aspectMenuOpen) return; const closeOnPointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Node && aspectControlRef.current?.contains(target)) return; setAspectMenuOpen(false); }; const closeOnKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setAspectMenuOpen(false); textareaRef.current?.focus(); } }; const closeOnScroll = () => setAspectMenuOpen(false); const closeOnWheel = (event: WheelEvent) => { setAspectMenuOpen(false); scrollNearestOverflowParent(event.target, event.deltaY); }; document.addEventListener("pointerdown", closeOnPointerDown, true); document.addEventListener("keydown", closeOnKeyDown); document.addEventListener("scroll", closeOnScroll, true); document.addEventListener("wheel", closeOnWheel, { capture: true, passive: true }); return () => { document.removeEventListener("pointerdown", closeOnPointerDown, true); document.removeEventListener("keydown", closeOnKeyDown); document.removeEventListener("scroll", closeOnScroll, true); document.removeEventListener("wheel", closeOnWheel, true); }; }, [aspectMenuOpen]); const resizeTextarea = useCallback(() => { requestAnimationFrame(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; el.focus(); }); }, []); const chooseSlashCommand = useCallback( (command: SlashCommand) => { setValue(command.argHint ? `${command.command} ` : command.command); setSlashMenuDismissed(true); setInlineError(null); resizeTextarea(); }, [resizeTextarea], ); const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); // Share the same normalized ``data:`` URL with both the wire payload and // the optimistic bubble preview: data URLs are self-contained (no blob // lifetime, safe under React StrictMode double-mount) and keep the // bubble in sync with whatever the backend actually sees. const payload: SendImage[] | undefined = readyImages.length > 0 ? readyImages.map((img) => ({ media: { data_url: img.dataUrl, name: img.file.name, }, preview: { url: img.dataUrl, name: img.file.name }, })) : undefined; const options: SendOptions | undefined = imageMode ? { imageGeneration: { enabled: true, aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio, }, } : undefined; onSend(trimmed, payload, options); setValue(""); setInlineError(null); // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); setSlashMenuDismissed(false); resizeTextarea(); }, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]); const onKeyDown = (e: ReactKeyboardEvent) => { if (showSlashMenu) { if (e.key === "ArrowDown") { e.preventDefault(); setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedCommandIndex( (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length, ); return; } if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { e.preventDefault(); chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]); return; } if (e.key === "Escape") { e.preventDefault(); setSlashMenuDismissed(true); return; } } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } }; const onInput: React.FormEventHandler = (e) => { const el = e.currentTarget; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; }; const onFilePick: React.ChangeEventHandler = (e) => { const files = Array.from(e.target.files ?? []); e.target.value = ""; addFiles(files); }; const removeChip = useCallback( (id: string) => { const { nextFocusId } = remove(id); setInlineError(null); requestAnimationFrame(() => { const el = nextFocusId ? chipRefs.current.get(nextFocusId) : null; if (el) { el.focus(); } else { textareaRef.current?.focus(); } }); }, [remove], ); const onChipKey = useCallback( (id: string) => (e: ReactKeyboardEvent) => { if ( e.key === "Delete" || e.key === "Backspace" || e.key === "Enter" || e.key === " " ) { e.preventDefault(); removeChip(id); } }, [removeChip], ); const attachButtonDisabled = disabled || full; const showStopButton = isStreaming && !!onStop; return (
{ e.preventDefault(); submit(); }} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} > {showSlashMenu ? ( ) : null}
{images.length > 0 ? (
{images.map((img) => ( t("thread.composer.normalizedSizeHint", { orig: formatBytes(orig), current: formatBytes(current), }) } formatError={formatRejection} onRemove={() => removeChip(img.id)} onKeyDown={onChipKey(img.id)} registerRef={(el) => { if (el) chipRefs.current.set(img.id, el); else chipRefs.current.delete(img.id); }} /> ))}
) : null}