import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { ArrowUp, Paperclip, Square } from 'lucide-react'; import { IS_WORDPRESS } from '../../constants'; import { useCancelCurrentStream, useSendMessage, useCurrentConversationId, useCurrentConversationState, } from '../../contexts/ChatContext'; import useRenderTracker from '../../hooks/useRenderTracker'; import { useAddComposerFiles, useClearComposerAttachments, useComposerAttachments, useComposerDraft, useComposerTextareaRef, useSetComposerDraft, } from '../../contexts/ComposerContext'; import { useWorkflows, type Workflow } from '../../contexts/WorkflowsContext'; import { useApplyWorkflow } from '../../hooks/useApplyWorkflow'; import { findNextPlaceholder } from '../../utils/workflowTemplate'; import { useFeatureFlag } from '../../featureFlags'; import { composerAttachmentToChatInput } from '../../services/attachments'; import ComposerAttachments from './ComposerAttachments'; import WorkflowSlashMenu from './WorkflowSlashMenu'; const SLASH_MENU_LIMIT = 8; interface InputSectionProps { scrollToBottom: (behavior: ScrollBehavior) => void; } const InputSection: React.FC = ({ scrollToBottom }) => { const sendMessage = useSendMessage(); const cancelCurrentStream = useCancelCurrentStream(); const currentConversationId = useCurrentConversationId(); const currentConversationState = useCurrentConversationState(); const inputValue = useComposerDraft(); const setInputValue = useSetComposerDraft(); const textareaRef = useComposerTextareaRef(); const isEnabled = !currentConversationId || currentConversationState?.phase === 'IDLE'; const isStreaming = currentConversationState?.phase === 'THINKING' || currentConversationState?.phase === 'STREAMING'; const showStop = isStreaming; const addFiles = useAddComposerFiles(); const clearAttachments = useClearComposerAttachments(); const attachments = useComposerAttachments(); const fileInputRef = useRef(null); const readyAttachments = useMemo( () => attachments.filter((attachment) => attachment.status === 'ready'), [attachments] ); const hasPendingAttachments = attachments.some((attachment) => attachment.status === 'uploading'); const hasContent = inputValue.trim() !== '' || readyAttachments.length > 0; const canSubmit = hasContent && isEnabled && !hasPendingAttachments; // Workflow "/" slash command: typing a single-line draft starting with "/" opens // a picker of matching workflows above the composer. Plugin-only (web isn't // site-connected, so it has no real workflows). const workflowsEnabled = useFeatureFlag('workflows') && IS_WORDPRESS; const { workflows } = useWorkflows(); const applyWorkflow = useApplyWorkflow(); const [activeSlashIndex, setActiveSlashIndex] = useState(0); const slashQuery = workflowsEnabled && inputValue.startsWith('/') && !inputValue.includes('\n') ? inputValue.slice(1).trim().toLowerCase() : null; const slashItems = useMemo(() => { if (slashQuery === null) return []; const matched = slashQuery ? workflows.filter((w) => `${w.title} ${w.description ?? ''}`.toLowerCase().includes(slashQuery)) : workflows; return matched.slice(0, SLASH_MENU_LIMIT); }, [slashQuery, workflows]); const slashOpen = slashItems.length > 0; useEffect(() => { setActiveSlashIndex(0); }, [slashQuery]); const selectWorkflow = useCallback( (workflow: Workflow) => { applyWorkflow(workflow); // replaces the "/query" draft with the expanded template }, [applyWorkflow] ); // Only track if we're at mobile breakpoint (sm:) const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' ? window.innerWidth < 640 : false ); useRenderTracker('InputSection', { currentConversationState, inputValue, isMobile, currentConversationId, sendMessage, scrollToBottom, }); useEffect(() => { const handleResize = () => { const mobile = window.innerWidth < 640; // Tailwind's sm: breakpoint setIsMobile(mobile); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }, [inputValue, isMobile, textareaRef]); const handleSubmit = useCallback( (e?: React.FormEvent) => { if (e) e.preventDefault(); const trimmedValue = inputValue.trim(); if (canSubmit) { const chatAttachments = readyAttachments .map(composerAttachmentToChatInput) .filter((attachment): attachment is NonNullable => Boolean(attachment)); sendMessage(trimmedValue, chatAttachments); setInputValue(''); clearAttachments(); setTimeout(() => { scrollToBottom('smooth'); }, 50); } }, [inputValue, canSubmit, readyAttachments, sendMessage, scrollToBottom, setInputValue, clearAttachments] ); const handlePaste = useCallback( (e: React.ClipboardEvent) => { // Pull any image files off the clipboard (screenshots, copied images). const imageFiles = Array.from(e.clipboardData.items) .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) .map((item) => item.getAsFile()) .filter((file): file is File => file !== null); if (imageFiles.length === 0) return; // Prevent the binary from also landing in the textarea as junk text, but let // normal text pastes through untouched (we returned early above). e.preventDefault(); addFiles(imageFiles); }, [addFiles] ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { // Slash-command menu navigation takes priority while it's open. if (slashOpen) { if (e.key === 'ArrowDown') { e.preventDefault(); setActiveSlashIndex((i) => (i + 1) % slashItems.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setActiveSlashIndex((i) => (i - 1 + slashItems.length) % slashItems.length); return; } if ((e.key === 'Enter' && !e.shiftKey) || e.key === 'Tab') { e.preventDefault(); selectWorkflow(slashItems[activeSlashIndex] ?? slashItems[0]); return; } if (e.key === 'Escape') { e.preventDefault(); setInputValue(''); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } else if (e.key === 'Tab') { e.preventDefault(); // If the draft has [placeholder] blanks (from a workflow), Tab jumps to the // next one and selects it; otherwise fall back to inserting a tab character. const next = findNextPlaceholder(inputValue, e.currentTarget.selectionEnd); if (next) { setTimeout(() => textareaRef.current?.setSelectionRange(next[0], next[1]), 0); return; } const start = e.currentTarget.selectionStart; const end = e.currentTarget.selectionEnd; setInputValue((prevValue) => prevValue.substring(0, start) + '\t' + prevValue.substring(end) ); // Set cursor position after tab setTimeout(() => { if (textareaRef.current) { textareaRef.current.selectionStart = textareaRef.current.selectionEnd = start + 1; } }, 0); } }, [slashOpen, slashItems, activeSlashIndex, selectWorkflow, inputValue, handleSubmit, setInputValue, textareaRef] ); return (
{slashOpen && ( )}