import { useTranslation } from 'react-i18next'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { ChangeEvent, ClipboardEvent, FormEvent, KeyboardEvent, MouseEvent, ReactNode, RefObject, TouchEvent, } from 'react'; import { ImageIcon, MessageSquareIcon, XIcon, ArrowDownIcon, Loader2 } from 'lucide-react'; import { useVoiceInput } from '../../hooks/useVoiceInput'; import { useVoiceAvailable } from '../../hooks/useVoiceAvailable'; import type { SessionActivity } from '../../../../hooks/useSessionProtection'; import type { PendingPermissionRequest, PermissionMode } from '../../types/types'; import { PromptInput, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputSubmit, } from '../../../../shared/view/ui'; import CommandMenu from './CommandMenu'; import ActivityIndicator from './ActivityIndicator'; import ImageAttachment from './ImageAttachment'; import VoiceInputButton from './VoiceInputButton'; import PermissionRequestsBanner from './PermissionRequestsBanner'; import TokenUsageSummary from './TokenUsageSummary'; interface MentionableFile { name: string; path: string; } interface SlashCommand { name: string; description?: string; namespace?: string; path?: string; type?: string; metadata?: Record; [key: string]: unknown; } interface ChatComposerProps { pendingPermissionRequests: PendingPermissionRequest[]; handlePermissionDecision: ( requestIds: string | string[], decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown }, ) => void; handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; activity: SessionActivity | null; isLoading: boolean; onAbortSession: () => void; permissionMode: PermissionMode | string; onModeSwitch: () => void; tokenBudget: Record | null; onShowTokenUsage: () => void; slashCommandsCount: number; onToggleCommandMenu: () => void; hasInput: boolean; onClearInput: () => void; isUserScrolledUp: boolean; hasMessages: boolean; onScrollToBottom: () => void; onSubmit: (event: FormEvent | MouseEvent | TouchEvent) => void; isDragActive: boolean; attachedImages: File[]; onRemoveImage: (index: number) => void; uploadingImages: Map; imageErrors: Map; showFileDropdown: boolean; filteredFiles: MentionableFile[]; selectedFileIndex: number; onSelectFile: (file: MentionableFile) => void; filteredCommands: SlashCommand[]; selectedCommandIndex: number; onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void; onCloseCommandMenu: () => void; isCommandMenuOpen: boolean; frequentCommands: SlashCommand[]; getRootProps: (...args: unknown[]) => Record; getInputProps: (...args: unknown[]) => Record; openImagePicker: () => void; inputHighlightRef: RefObject; renderInputWithMentions: (text: string) => ReactNode; textareaRef: RefObject; input: string; onVoiceTranscript?: (text: string, send?: boolean) => void; onInputChange: (event: ChangeEvent) => void; onTextareaClick: (event: MouseEvent) => void; onTextareaKeyDown: (event: KeyboardEvent) => void; onTextareaPaste: (event: ClipboardEvent) => void; onTextareaScrollSync: (target: HTMLTextAreaElement) => void; onTextareaInput: (event: FormEvent) => void; onInputFocusChange?: (focused: boolean) => void; placeholder: string; isTextareaExpanded: boolean; sendByCtrlEnter?: boolean; } export default function ChatComposer({ pendingPermissionRequests, handlePermissionDecision, handleGrantToolPermission, activity, isLoading, onAbortSession, permissionMode, onModeSwitch, tokenBudget, onShowTokenUsage, slashCommandsCount, onToggleCommandMenu, hasInput, onClearInput, isUserScrolledUp, hasMessages, onScrollToBottom, onSubmit, isDragActive, attachedImages, onRemoveImage, uploadingImages, imageErrors, showFileDropdown, filteredFiles, selectedFileIndex, onSelectFile, filteredCommands, selectedCommandIndex, onCommandSelect, onCloseCommandMenu, isCommandMenuOpen, frequentCommands, getRootProps, getInputProps, openImagePicker, inputHighlightRef, renderInputWithMentions, textareaRef, input, onVoiceTranscript, onInputChange, onTextareaClick, onTextareaKeyDown, onTextareaPaste, onTextareaScrollSync, onTextareaInput, onInputFocusChange, placeholder, isTextareaExpanded, sendByCtrlEnter, }: ChatComposerProps) { const { t } = useTranslation('chat'); // Voice state is hosted here (not in the mic button) so the main Send button can stop // recording and send the transcript in one tap, the way the mic button drops it in the box. const voiceAvailable = useVoiceAvailable(); const [voiceError, setVoiceError] = useState(null); const voiceErrorTimer = useRef | null>(null); const handleVoiceError = useCallback((msg: string) => { setVoiceError(msg); if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current); voiceErrorTimer.current = setTimeout(() => setVoiceError(null), 4000); }, []); useEffect(() => () => { if (voiceErrorTimer.current) clearTimeout(voiceErrorTimer.current); }, []); const noopTranscript = useCallback(() => {}, []); const { state: voiceState, toggle: voiceToggle, stop: voiceStop } = useVoiceInput( onVoiceTranscript ?? noopTranscript, handleVoiceError, ); const isRecording = voiceState === 'recording'; const isTranscribing = voiceState === 'transcribing'; const textareaRect = textareaRef.current?.getBoundingClientRect(); const commandMenuPosition = { top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0, left: textareaRect ? textareaRect.left : 16, bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90, }; // Detect if the AskUserQuestion interactive panel is active const hasQuestionPanel = pendingPermissionRequests.some( (r) => r.toolName === 'AskUserQuestion' ); // Hide the thinking/status bar while any permission request is pending const hasPendingPermissions = pendingPermissionRequests.length > 0; return (
{!hasPendingPermissions && ( )} {pendingPermissionRequests.length > 0 && (
)} {!hasQuestionPanel &&
{isUserScrolledUp && hasMessages && (
)} {showFileDropdown && filteredFiles.length > 0 && (
{filteredFiles.map((file, index) => (
{ event.preventDefault(); event.stopPropagation(); }} onClick={(event) => { event.preventDefault(); event.stopPropagation(); onSelectFile(file); }} >
{file.name}
{file.path}
))}
)} ) => void} status={isLoading ? 'streaming' : 'ready'} className={isTextareaExpanded ? 'chat-input-expanded' : ''} {...getRootProps()} > {isDragActive && (

Drop images here

)} {attachedImages.length > 0 && (
{attachedImages.map((file, index) => ( onRemoveImage(index)} uploadProgress={uploadingImages.get(file.name)} error={imageErrors.get(file.name)} /> ))}
)} onTextareaScrollSync(event.target as HTMLTextAreaElement)} onFocus={() => onInputFocusChange?.(true)} onBlur={() => onInputFocusChange?.(false)} onInput={onTextareaInput} placeholder={placeholder} /> {onVoiceTranscript && voiceAvailable && ( )} {slashCommandsCount > 0 && ( {slashCommandsCount} )} {hasInput && ( )}
{sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}
) => { e.preventDefault(); voiceStop({ send: true }); } : undefined } disabled={isLoading ? false : isRecording ? false : isTranscribing ? true : !input.trim()} className="h-10 w-10 sm:h-10 sm:w-10" > {isTranscribing ? : undefined}
}
); }