import { ArrowUp, BookMarked, BookmarkPlus, Globe, Paperclip, Plus, Square, SquareDashedMousePointer, X, } from "lucide-react" import type React from "react" import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react" import { toast } from "sonner" import { TemplateCreateDialog } from "@/components/chat/TemplateCreateDialog" import { TemplatePanel } from "@/components/chat/TemplatePanel" import { ErrorToast } from "@/components/error-toast" import { FilePreviewList } from "@/components/file-preview-list" import { ModelSelector } from "@/components/model-selector" import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { UrlInputDialog } from "@/components/url-input-dialog" import { IconButton } from "@/components/workspace/icon-button" import { useDictionary } from "@/hooks/use-dictionary" import { formatMessage } from "@/lib/i18n/utils" import { modKey } from "@/lib/platform" import { extractUrlContent } from "@/lib/url-utils" import { cn } from "@/lib/utils" import { useSettingsStore } from "@/stores/settings-store" import { useUiStore } from "@/stores/ui-store" import { ATTACHMENT_ACCEPT, validateFiles } from "./attachments" import { useChatEngine } from "./chat-engine" function showErrorToast(message: React.ReactNode) { toast.custom( (t) => ( toast.dismiss(t)} /> ), { duration: 5000 }, ) } function showValidationErrors(errors: string[], dict: any) { if (errors.length === 0) return if (errors.length === 1) { showErrorToast( {errors[0]}, ) return } showErrorToast(
{formatMessage(dict.errors.filesRejected, { count: errors.length, })}
, ) } function MenuItem({ icon, children, onClick, disabled, disabledHint, }: { icon: React.ReactNode children: React.ReactNode onClick: () => void disabled?: boolean /** Why the item can't be used now, shown under it while disabled */ disabledHint?: string }) { return ( ) } /** * The message box: text, attachments, the selected shapes, the model picker * and send/stop. `variant="hero"` is the large box on the start screen. */ export function Composer({ variant = "panel", placeholder, autoFocus = false, }: { variant?: "panel" | "hero" placeholder?: string autoFocus?: boolean }) { const dict = useDictionary() const engine = useChatEngine() const { input, setInput, files, setFiles, pdfData, urlData, setUrlData, isBusy, error, chatSelection, dismissSelection, modelConfig, } = engine const sendShortcut = useSettingsStore((s) => s.sendShortcut) const openSettings = useUiStore((s) => s.openSettings) const focusToken = useUiStore((s) => s.focusComposerToken) const textareaRef = useRef(null) const fileInputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const [addOpen, setAddOpen] = useState(false) const [templatesOpen, setTemplatesOpen] = useState(false) const [showUrlDialog, setShowUrlDialog] = useState(false) const [isExtractingUrl, setIsExtractingUrl] = useState(false) const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) const isHero = variant === "hero" // Allow a retry when the last request failed, even mid-stream. While // the chat is being left, what is typed could not be sent. const isDisabled = (isBusy && !error) || engine.isLeaving const canSend = !isDisabled && !engine.isExtractingAttachments && !!input.trim() const adjustHeight = useCallback(() => { const textarea = textareaRef.current if (!textarea) return textarea.style.height = "auto" textarea.style.height = `${Math.min(textarea.scrollHeight, 220)}px` }, []) useEffect(() => { adjustHeight() }, [input, adjustHeight]) // Focus requests from elsewhere (new chat, "ask AI" button, ⌘/) useEffect(() => { if (focusToken === 0 && !autoFocus) return const timer = setTimeout(() => textareaRef.current?.focus(), 60) return () => clearTimeout(timer) }, [focusToken, autoFocus]) // The chat is left while the user types (browser back): the browser // takes the focus from the input it disables, after this runs. It gets // the focus back once enabled, unless something else took it meanwhile. const refocusRef = useRef(false) const isLeaving = engine.isLeaving useLayoutEffect(() => { if (isDisabled) { if (isLeaving && document.activeElement === textareaRef.current) { refocusRef.current = true } return } if (!refocusRef.current) return refocusRef.current = false if (document.activeElement === document.body) { textareaRef.current?.focus() } }, [isDisabled, isLeaving]) const addFiles = (newFiles: File[]) => { const { validFiles, errors } = validateFiles( newFiles, files.length, dict, ) showValidationErrors(errors, dict) if (validFiles.length > 0) setFiles([...files, ...validFiles]) } const handleKeyDown = (e: React.KeyboardEvent) => { // Enter that confirms an IME candidate must not send the message if (e.nativeEvent.isComposing || e.keyCode === 229) return const shouldSend = sendShortcut === "enter" ? e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey : (e.metaKey || e.ctrlKey) && e.key === "Enter" if (shouldSend) { e.preventDefault() if (canSend) engine.submit() } } const handlePaste = async (e: React.ClipboardEvent) => { if (isDisabled) return const imageItems = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/"), ) if (imageItems.length === 0) return const imageFiles = imageItems .map((item, index) => { const file = item.getAsFile() if (!file) return null return new File( [file], `pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`, { type: file.type }, ) }) .filter((f): f is File => f !== null) addFiles(imageFiles) } const handleUrlExtract = async (url: string) => { setIsExtractingUrl(true) // Functional updates, so a removal or send made while extracting is // not overwritten when the request finishes try { setUrlData((prev) => new Map(prev).set(url, { url, title: url, content: "", charCount: 0, isExtracting: true, }), ) const data = await extractUrlContent(url) setUrlData((prev) => prev.has(url) ? new Map(prev).set(url, data) : prev, ) setShowUrlDialog(false) } catch (err) { setUrlData((prev) => { const next = new Map(prev) next.delete(url) return next }) showErrorToast( {err instanceof Error ? err.message : dict.url.failed} , ) } finally { setIsExtractingUrl(false) } } const selectionLabel = chatSelection.length === 1 ? formatMessage(dict.selection.one, { label: chatSelection[0].label || (chatSelection[0].isEdge ? dict.selection.connector : dict.selection.shape), }) : formatMessage(dict.selection.many, { count: chatSelection.length, }) const hasChips = chatSelection.length > 0 || files.length > 0 || urlData.size > 0 return (
{ e.preventDefault() if (canSend) engine.submit() }} onDragOver={(e) => { e.preventDefault() setIsDragging(true) }} onDragLeave={(e) => { e.preventDefault() setIsDragging(false) }} onDrop={(e) => { e.preventDefault() setIsDragging(false) if (!isDisabled) addFiles(Array.from(e.dataTransfer.files)) }} className={cn( "group/composer relative w-full rounded-[14px] border border-border bg-card transition-[border-color,box-shadow] duration-150", "focus-within:border-foreground/20 focus-within:shadow-[0_0_0_4px_var(--accent)]", isHero && "rounded-[18px] border-transparent shadow-pop", isDragging && "border-foreground/30 shadow-[0_0_0_4px_var(--marker-soft)]", )} > {hasChips && (
{chatSelection.length > 0 && ( {selectionLabel} )} {(files.length > 0 || urlData.size > 0) && ( // A short window keeps room for the buttons below
setFiles(files.filter((f) => f !== file)) } pdfData={pdfData} urlData={urlData} onRemoveUrl={(url) => setUrlData((prev) => { const next = new Map(prev) next.delete(url) return next }) } />
)}
)}