import { AppEvents } from '../constants/events'; import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react'; import { Bug, ChefHat, ScrollText } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Button } from './ui/button'; import type { View } from '../utils/navigationUtils'; import Stop from './ui/Stop'; import { Attach, Send, Close, Microphone } from './icons'; import { ChatState } from '../types/chatState'; import debounce from 'lodash/debounce'; import { LocalMessageStorage } from '../utils/localMessageStorage'; import { DirSwitcher } from './bottom_menu/DirSwitcher'; import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection'; import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection'; import { AlertType, useAlerts } from './alerts'; import { useConfig } from './ConfigContext'; import { useModelAndProvider } from './ModelAndProviderContext'; import { useAudioRecorder } from '../hooks/useAudioRecorder'; import { toastError } from '../toasts'; import MentionPopover, { DisplayItemWithMatch } from './MentionPopover'; import { COST_TRACKING_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; import { Recipe } from '../recipe'; import { MessageQueue, QueuedMessage } from './MessageQueue'; import { detectInterruption } from '../utils/interruptionDetector'; import { DiagnosticsModal } from './ui/Diagnostics'; import { getSession, Message } from '../api'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import CreateEditRecipeModal from './recipes/CreateEditRecipeModal'; import { getInitialWorkingDir } from '../utils/workingDir'; import { getPredefinedModelsFromEnv } from './settings/models/predefinedModelsUtils'; import { trackFileAttached, trackVoiceDictation, trackDiagnosticsOpened, trackCreateRecipeOpened, trackEditRecipeOpened, } from '../utils/analytics'; import { getNavigationShortcutText } from '../utils/keyboardShortcuts'; import { UserInput, ImageData } from '../types/message'; import { compressImageDataUrl } from '../utils/conversionUtils'; import { fetchCanonicalModelInfo } from '../utils/canonical'; interface PastedImage { id: string; dataUrl: string; isLoading: boolean; error?: string; } const MAX_IMAGES_PER_MESSAGE = 10; // Constants for token and tool alerts const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning // Manual compact trigger message - must match backend constant const MANUAL_COMPACT_TRIGGER = '/compact'; interface ChatInputProps { sessionId: string | null; handleSubmit: (input: UserInput) => void; chatState: ChatState; setChatState?: (state: ChatState) => void; onStop?: () => void; commandHistory?: string[]; initialValue?: string; droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; setView: (view: View) => void; totalTokens?: number; accumulatedInputTokens?: number; accumulatedOutputTokens?: number; messages?: Message[]; sessionCosts?: { [key: string]: { inputTokens: number; outputTokens: number; totalCost: number; }; }; disableAnimation?: boolean; recipe?: Recipe | null; recipeId?: string | null; recipeAccepted?: boolean; initialPrompt?: string; toolCount: number; append?: (message: Message) => void; onWorkingDirChange?: (newDir: string) => void; inputRef?: React.RefObject; sessionModel?: string | null; sessionProvider?: string | null; sessionLoaded?: boolean; } export default function ChatInput({ sessionId, handleSubmit, chatState = ChatState.Idle, setChatState, onStop, commandHistory = [], initialValue = '', droppedFiles = [], onFilesProcessed, setView, totalTokens, accumulatedInputTokens, accumulatedOutputTokens, messages = [], disableAnimation = false, sessionCosts, recipe, recipeId, recipeAccepted, initialPrompt, toolCount, append: _append, onWorkingDirChange, inputRef, sessionModel, sessionProvider, sessionLoaded, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); const [pastedImages, setPastedImages] = useState([]); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); // Derived state - chatState != Idle means we're in some form of loading state const isLoading = chatState !== ChatState.Idle; const wasLoadingRef = useRef(isLoading); // Queue functionality - ephemeral, only exists in memory for this chat instance const [queuedMessages, setQueuedMessages] = useState([]); const queuePausedRef = useRef(false); const editingMessageIdRef = useRef(null); const [lastInterruption, setLastInterruption] = useState(null); const { alerts, addAlert, clearAlerts } = useAlerts(); const dropdownRef: React.RefObject = useRef( null ) as React.RefObject; const { getProviders } = useConfig(); const { getCurrentModelAndProvider, currentModel: configModel, currentProvider: configProvider } = useModelAndProvider(); // Local override for when the user changes the model in the modal, // before the session object is re-fetched from the backend. const [modelOverride, setModelOverride] = useState<{ model: string; provider: string } | null>(null); const effectiveModel = modelOverride?.model ?? sessionModel ?? configModel; const effectiveProvider = modelOverride?.provider ?? sessionProvider ?? configProvider; // Clear override when the underlying data catches up (session props for // active chats, config defaults for Hub / no-session contexts). useEffect(() => { if (!modelOverride) return; const sessionCaughtUp = sessionModel === modelOverride.model && sessionProvider === modelOverride.provider; const configCaughtUp = !sessionId && configModel === modelOverride.model && configProvider === modelOverride.provider; if (sessionCaughtUp || configCaughtUp) { setModelOverride(null); } }, [sessionModel, sessionProvider, configModel, configProvider, sessionId, modelOverride]); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [showCreateRecipeModal, setShowCreateRecipeModal] = useState(false); const [showEditRecipeModal, setShowEditRecipeModal] = useState(false); const [sessionWorkingDir, setSessionWorkingDir] = useState(null); useEffect(() => { if (!sessionId) { return; } const fetchSessionWorkingDir = async () => { try { const response = await getSession({ path: { session_id: sessionId } }); if (response.data?.working_dir) { setSessionWorkingDir(response.data.working_dir); } } catch (error) { console.error('[ChatInput] Failed to fetch session working dir:', error); } }; fetchSessionWorkingDir(); }, [sessionId]); // Save queue state (paused/interrupted) to storage useEffect(() => { try { window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current)); } catch (error) { console.error('Error saving queue pause state:', error); } }, [queuedMessages]); // Save when queue changes useEffect(() => { try { window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption)); } catch (error) { console.error('Error saving queue interruption state:', error); } }, [lastInterruption]); // Cleanup effect - save final state on component unmount useEffect(() => { return () => { // Save final queue state when component unmounts try { window.sessionStorage.setItem('goose-queue-paused', JSON.stringify(queuePausedRef.current)); window.sessionStorage.setItem('goose-queue-interruption', JSON.stringify(lastInterruption)); } catch (error) { console.error('Error saving queue state on unmount:', error); } }; }, [lastInterruption]); // Include lastInterruption in dependency array // Queue processing useEffect(() => { if (wasLoadingRef.current && !isLoading && queuedMessages.length > 0) { // After an interruption, we should process the interruption message immediately // The queue is only truly paused if there was an interruption AND we want to keep it paused const shouldProcessQueue = !queuePausedRef.current || lastInterruption; if (shouldProcessQueue) { const nextMessage = queuedMessages[0]; LocalMessageStorage.addMessage(nextMessage.content); handleSubmit({ msg: nextMessage.content, images: nextMessage.images }); setQueuedMessages((prev) => { const newQueue = prev.slice(1); // If queue becomes empty after processing, clear the paused state if (newQueue.length === 0) { queuePausedRef.current = false; setLastInterruption(null); } return newQueue; }); // Clear the interruption flag after processing the interruption message if (lastInterruption) { setLastInterruption(null); // Keep the queue paused after sending the interruption message // User can manually resume if they want to continue with queued messages queuePausedRef.current = true; } } } wasLoadingRef.current = isLoading; }, [isLoading, queuedMessages, handleSubmit, lastInterruption]); const [mentionPopover, setMentionPopover] = useState<{ isOpen: boolean; position: { x: number; y: number }; query: string; mentionStart: number; selectedIndex: number; isSlashCommand: boolean; }>({ isOpen: false, position: { x: 0, y: 0 }, query: '', mentionStart: -1, selectedIndex: 0, isSlashCommand: false, }); const mentionPopoverRef = useRef<{ getDisplayFiles: () => DisplayItemWithMatch[]; selectFile: (index: number) => void; }>(null); // Audio recorder hook for voice dictation const { isEnabled, dictationProvider, isRecording, isTranscribing, startRecording, stopRecording, } = useAudioRecorder({ onTranscription: (text) => { trackVoiceDictation('transcribed'); let filteredText = text.replace(/\([^)]*\)/g, '').trim(); if (!filteredText) { return; } const shouldAutoSubmit = /\bsubmit[.,!?;'"\s]*$/i.test(filteredText); const cleanedText = shouldAutoSubmit ? filteredText.replace(/\bsubmit[.,!?;'"\s]*$/i, '').trim() : filteredText; const newValue = displayValue.trim() && cleanedText ? `${displayValue.trim()} ${cleanedText}` : displayValue.trim() || cleanedText; setDisplayValue(newValue); setValue(newValue); if (shouldAutoSubmit && newValue.trim()) { trackVoiceDictation('auto_submit'); setTimeout(() => { performSubmit(newValue); }, 100); } else { textAreaRef.current?.focus(); } }, onError: (message) => { const errorType = 'DictationError'; trackVoiceDictation('error', undefined, errorType); toastError({ title: 'Dictation Error', msg: message, }); }, }); const internalTextAreaRef = useRef(null); const textAreaRef = inputRef || internalTextAreaRef; const timeoutRefsRef = useRef>>(new Set()); useEffect(() => { setValue(initialValue); setDisplayValue(initialValue); setPastedImages([]); setHistoryIndex(-1); setIsInGlobalHistory(false); setHasUserTyped(false); }, [initialValue]); // Handle recipe prompt updates useEffect(() => { // If recipe is accepted and we have an initial prompt, and no messages yet, and we haven't set it before if (recipeAccepted && initialPrompt && messages.length === 0) { setDisplayValue(initialPrompt); setValue(initialPrompt); setTimeout(() => { textAreaRef.current?.focus(); }, 0); } }, [recipeAccepted, initialPrompt, messages.length, textAreaRef]); const [isComposing, setIsComposing] = useState(false); const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(''); const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const [hasUserTyped, setHasUserTyped] = useState(false); // Use shared file drop hook for ChatInput const { droppedFiles: localDroppedFiles, setDroppedFiles: setLocalDroppedFiles, handleDrop: handleLocalDrop, handleDragOver: handleLocalDragOver, } = useFileDrop(); // Merge local dropped files with parent dropped files const allDroppedFiles = useMemo( () => [...droppedFiles, ...localDroppedFiles], [droppedFiles, localDroppedFiles] ); const handleRemoveDroppedFile = (idToRemove: string) => { // Remove from local dropped files setLocalDroppedFiles((prev) => prev.filter((file) => file.id !== idToRemove)); // If it's from parent, call the parent's callback if (onFilesProcessed && droppedFiles.some((file) => file.id === idToRemove)) { onFilesProcessed(); } }; const handleRemovePastedImage = (idToRemove: string) => { setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove)); }; useEffect(() => { if (textAreaRef.current) { textAreaRef.current.focus(); } }, [textAreaRef]); // Load providers and get current model's token limit const loadProviderDetails = async () => { try { // Reset token limit loaded state setIsTokenLimitLoaded(false); // Use effective model/provider (includes overrides from in-session model changes), // fall back to config defaults let model = effectiveModel; let provider = effectiveProvider; if (!model || !provider) { const configModelAndProvider = await getCurrentModelAndProvider(); model = configModelAndProvider.model; provider = configModelAndProvider.provider; } if (!model || !provider) { console.log('No model or provider found'); setIsTokenLimitLoaded(true); return; } // Priority 1: Check predefined models from environment const predefinedModels = getPredefinedModelsFromEnv(); const predefinedModel = predefinedModels.find((m) => m.name === model); if (predefinedModel?.context_limit) { setTokenLimit(predefinedModel.context_limit); setIsTokenLimitLoaded(true); return; } // Priority 2: Check canonical model info (source of truth) const canonicalInfo = await fetchCanonicalModelInfo(provider, model); if (canonicalInfo?.context_limit) { setTokenLimit(canonicalInfo.context_limit); setIsTokenLimitLoaded(true); return; } // Priority 3: Fall back to provider metadata known_models (may be outdated) const providers = await getProviders(true); const currentProvider = providers.find((p) => p.name === provider); if (currentProvider?.metadata?.known_models) { const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model); if (modelConfig?.context_limit) { setTokenLimit(modelConfig.context_limit); setIsTokenLimitLoaded(true); return; } } // Priority 4: Use default if nothing else found setTokenLimit(TOKEN_LIMIT_DEFAULT); setIsTokenLimitLoaded(true); } catch (err) { console.error('Error loading providers or token limit:', err); // Set default limit on error setTokenLimit(TOKEN_LIMIT_DEFAULT); setIsTokenLimitLoaded(true); } }; // Initial load and refresh when model changes (effective model includes overrides, // config model is the fallback for Hub/no-session contexts) useEffect(() => { loadProviderDetails(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveModel, effectiveProvider, configModel, configProvider]); // Handle tool count alerts and token usage useEffect(() => { clearAlerts(); // Show alert when either there is registered token usage, or we know the limit if ((totalTokens && totalTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { addAlert({ type: AlertType.Info, message: 'Context window', progress: { current: totalTokens || 0, total: tokenLimit, }, showCompactButton: true, compactButtonDisabled: !totalTokens, onCompact: () => { window.dispatchEvent(new CustomEvent(AppEvents.HIDE_ALERT_POPOVER)); handleSubmit({ msg: MANUAL_COMPACT_TRIGGER, images: [] }); }, compactIcon: , }); } // Add tool count alert if we have the data if (toolCount !== null && toolCount > TOOLS_MAX_SUGGESTED) { addAlert({ type: AlertType.Warning, message: `Too many tools can degrade performance.\nTool count: ${toolCount} (recommend: ${TOOLS_MAX_SUGGESTED})`, action: { text: 'View extensions', onClick: () => setView('extensions'), }, autoShow: false, // Don't auto-show tool count warnings }); } // We intentionally omit setView as it shouldn't trigger a re-render of alerts // eslint-disable-next-line react-hooks/exhaustive-deps }, [totalTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { return () => { // Clear all tracked timeouts // eslint-disable-next-line react-hooks/exhaustive-deps const timeouts = timeoutRefsRef.current; timeouts.forEach((timeoutId) => { window.clearTimeout(timeoutId); }); timeouts.clear(); // Clear alerts to prevent memory leaks clearAlerts(); }; }, [clearAlerts]); const maxHeight = 10 * 24; // Immediate function to update actual value - no debounce for better responsiveness const updateValue = React.useCallback((value: string) => { setValue(value); }, []); const minTextareaHeight = 38; const debouncedAutosize = useMemo( () => debounce((element: HTMLTextAreaElement) => { // Store current scroll position to prevent jump const scrollTop = element.scrollTop; // Temporarily set to auto to measure natural height, but use minHeight to prevent collapse element.style.height = `${minTextareaHeight}px`; const scrollHeight = element.scrollHeight; const newHeight = Math.max(minTextareaHeight, Math.min(scrollHeight, maxHeight)); element.style.height = `${newHeight}px`; // Restore scroll position element.scrollTop = scrollTop; }, 50), [maxHeight, minTextareaHeight] ); useEffect(() => { if (textAreaRef.current) { debouncedAutosize(textAreaRef.current); } }, [debouncedAutosize, displayValue, textAreaRef]); // Set consistent minimum height when displayValue is empty useEffect(() => { if (textAreaRef.current && displayValue === '') { textAreaRef.current.style.height = `${minTextareaHeight}px`; } }, [displayValue, textAreaRef, minTextareaHeight]); const handleChange = (evt: React.ChangeEvent) => { const val = evt.target.value; const cursorPosition = evt.target.selectionStart; setDisplayValue(val); updateValue(val); setHasUserTyped(true); checkForMentionOrSlash(val, cursorPosition, evt.target); }; const checkForMentionOrSlash = ( text: string, cursorPosition: number, textArea: HTMLTextAreaElement ) => { const isSlashCommand = text.startsWith('/'); const beforeCursor = text.slice(0, cursorPosition); const lastAtIndex = isSlashCommand ? 0 : beforeCursor.lastIndexOf('@'); if (lastAtIndex === -1) { // No @ found, close mention popover setMentionPopover((prev) => ({ ...prev, isOpen: false })); return; } // Check if there's a space between @ and cursor (which would end the mention) const afterAt = beforeCursor.slice(lastAtIndex + 1); if (afterAt.includes(' ') || afterAt.includes('\n')) { setMentionPopover((prev) => ({ ...prev, isOpen: false })); return; } // Calculate position for the popover - position it above the chat input const textAreaRect = textArea.getBoundingClientRect(); setMentionPopover((prev) => ({ ...prev, isOpen: true, position: { x: textAreaRect.left, y: textAreaRect.top, // Position at the top of the textarea }, query: afterAt, mentionStart: lastAtIndex, selectedIndex: 0, // Reset selection when query changes isSlashCommand, // filteredFiles will be populated by the MentionPopover component })); }; const convertImagesToImageData = useCallback((): ImageData[] => { const pastedImageData: ImageData[] = pastedImages .filter((img) => img.dataUrl && !img.error && !img.isLoading) .map((img) => { const matches = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (matches) { return { data: matches[2], mimeType: matches[1], }; } return null; }) .filter((img): img is ImageData => img !== null); const droppedImageData: ImageData[] = allDroppedFiles .filter((file) => file.isImage && file.dataUrl && !file.error && !file.isLoading) .map((file) => { const matches = file.dataUrl!.match(/^data:([^;]+);base64,(.+)$/); if (matches) { return { data: matches[2], mimeType: matches[1], }; } return null; }) .filter((img): img is ImageData => img !== null); return [...pastedImageData, ...droppedImageData]; }, [pastedImages, allDroppedFiles]); const appendDroppedFilePaths = useCallback( (text: string): string => { const droppedFilePaths = allDroppedFiles .filter((file) => !file.isImage && !file.error && !file.isLoading) .map((file) => file.path); if (droppedFilePaths.length > 0) { const pathsString = droppedFilePaths.join(' '); return text ? `${text} ${pathsString}` : pathsString; } return text; }, [allDroppedFiles] ); const clearInputState = useCallback(() => { setDisplayValue(''); setValue(''); setPastedImages([]); if (onFilesProcessed && droppedFiles.length > 0) { onFilesProcessed(); } if (localDroppedFiles.length > 0) { setLocalDroppedFiles([]); } }, [droppedFiles.length, localDroppedFiles.length, onFilesProcessed, setLocalDroppedFiles]); const handlePaste = async (evt: React.ClipboardEvent) => { const files = Array.from(evt.clipboardData.files || []); const imageFiles = files.filter((file) => file.type.startsWith('image/')); if (imageFiles.length === 0) return; // Check if adding these images would exceed the limit if (pastedImages.length + imageFiles.length > MAX_IMAGES_PER_MESSAGE) { // Show error message to user setPastedImages((prev) => [ ...prev, { id: `error-${Date.now()}`, dataUrl: '', isLoading: false, error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed. Currently have ${pastedImages.length}.`, }, ]); // Remove the error message after 5 seconds with cleanup tracking const timeoutId = setTimeout(() => { setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); timeoutRefsRef.current.delete(timeoutId); }, 5000); timeoutRefsRef.current.add(timeoutId); return; } evt.preventDefault(); // Process each image file const newImages: PastedImage[] = []; for (const file of imageFiles) { const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; // Add the image with loading state newImages.push({ id: imageId, dataUrl: '', isLoading: true, }); // Process the image asynchronously const reader = new FileReader(); reader.onload = async (e) => { const dataUrl = e.target?.result as string; if (dataUrl) { const compressedDataUrl = await compressImageDataUrl(dataUrl); setPastedImages((prev) => prev.map((img) => img.id === imageId ? { ...img, dataUrl: compressedDataUrl, isLoading: false } : img ) ); } }; reader.onerror = () => { console.error('Failed to read image file:', file.name); setPastedImages((prev) => prev.map((img) => img.id === imageId ? { ...img, error: 'Failed to read image file.', isLoading: false } : img ) ); }; reader.readAsDataURL(file); } // Add all new images to the existing list setPastedImages((prev) => [...prev, ...newImages]); }; // Cleanup debounced functions on unmount useEffect(() => { return () => { debouncedAutosize.cancel?.(); }; }, [debouncedAutosize]); // Handlers for composition events, which are crucial for proper IME behavior const handleCompositionStart = () => { setIsComposing(true); }; const handleCompositionEnd = () => { setIsComposing(false); }; const handleHistoryNavigation = (evt: React.KeyboardEvent) => { const isUp = evt.key === 'ArrowUp'; const isDown = evt.key === 'ArrowDown'; // Only handle up/down keys with Cmd/Ctrl modifier if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) { return; } // Only prevent history navigation if the user has actively typed something // This allows history navigation when text is populated from history or other sources // but prevents it when the user is actively editing text if (hasUserTyped && displayValue.trim() !== '') { return; } evt.preventDefault(); // Get global history once to avoid multiple calls const globalHistory = LocalMessageStorage.getRecentMessages() || []; // Save current input if we're just starting to navigate history if (historyIndex === -1) { setSavedInput(displayValue || ''); setIsInGlobalHistory(commandHistory.length === 0); } // Determine which history we're using const currentHistory = isInGlobalHistory ? globalHistory : commandHistory; let newIndex = historyIndex; let newValue = ''; // Handle navigation if (isUp) { // Moving up through history if (newIndex < currentHistory.length - 1) { // Still have items in current history newIndex = historyIndex + 1; newValue = currentHistory[newIndex]; } else if (!isInGlobalHistory && globalHistory.length > 0) { // Switch to global history setIsInGlobalHistory(true); newIndex = 0; newValue = globalHistory[newIndex]; } } else { // Moving down through history if (newIndex > 0) { // Still have items in current history newIndex = historyIndex - 1; newValue = currentHistory[newIndex]; } else if (isInGlobalHistory && commandHistory.length > 0) { // Switch to chat history setIsInGlobalHistory(false); newIndex = commandHistory.length - 1; newValue = commandHistory[newIndex]; } else { // Return to original input newIndex = -1; newValue = savedInput; } } // Update display if we have a new value if (newIndex !== historyIndex) { setHistoryIndex(newIndex); if (newIndex === -1) { setDisplayValue(savedInput || ''); setValue(savedInput || ''); } else { setDisplayValue(newValue || ''); setValue(newValue || ''); } // Reset hasUserTyped when we populate from history setHasUserTyped(false); } }; const handleInterruptionAndQueue = () => { if (!isLoading || !hasSubmittableContent) { return false; } const imageData = convertImagesToImageData(); const contentToQueue = appendDroppedFilePaths(displayValue.trim()); const interruptionMatch = detectInterruption(displayValue.trim()); if (interruptionMatch && interruptionMatch.shouldInterrupt) { setLastInterruption(interruptionMatch.matchedText); if (onStop) onStop(); queuePausedRef.current = true; // For interruptions, we need to queue the message to be sent after the stop completes // rather than trying to send it immediately while the system is still loading const interruptionMessage: QueuedMessage = { id: Date.now().toString() + Math.random().toString(36).substr(2, 9), content: contentToQueue, timestamp: Date.now(), images: imageData, }; // Add the interruption message to the front of the queue so it gets sent first setQueuedMessages((prev) => [interruptionMessage, ...prev]); clearInputState(); return true; } const newMessage: QueuedMessage = { id: Date.now().toString() + Math.random().toString(36).substr(2, 9), content: contentToQueue, timestamp: Date.now(), images: imageData, }; setQueuedMessages((prev) => { const newQueue = [...prev, newMessage]; // If adding to an empty queue, reset the paused state if (prev.length === 0) { queuePausedRef.current = false; setLastInterruption(null); } return newQueue; }); clearInputState(); return true; }; const canSubmit = !isLoading && (displayValue.trim() || pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) || allDroppedFiles.some((file) => !file.error && !file.isLoading)); const performSubmit = useCallback( (text?: string) => { const imageData = convertImagesToImageData(); const textToSend = appendDroppedFilePaths(text ?? displayValue.trim()); if (textToSend || imageData.length > 0) { // Store original message in history if (displayValue.trim()) { LocalMessageStorage.addMessage(displayValue); } else { const droppedFilePaths = allDroppedFiles .filter((file) => !file.isImage && !file.error && !file.isLoading) .map((file) => file.path); if (droppedFilePaths.length > 0) { LocalMessageStorage.addMessage(droppedFilePaths.join(' ')); } } handleSubmit({ msg: textToSend, images: imageData }); // Auto-resume queue after sending a NON-interruption message (if it was paused due to interruption) if ( queuePausedRef.current && lastInterruption && textToSend && !detectInterruption(textToSend) ) { queuePausedRef.current = false; setLastInterruption(null); } clearInputState(); setHistoryIndex(-1); setSavedInput(''); setIsInGlobalHistory(false); setHasUserTyped(false); } }, [ convertImagesToImageData, appendDroppedFilePaths, displayValue, allDroppedFiles, handleSubmit, lastInterruption, clearInputState, ] ); const handleKeyDown = (evt: React.KeyboardEvent) => { if (mentionPopover.isOpen && mentionPopoverRef.current) { if (evt.key === 'ArrowDown') { evt.preventDefault(); const displayFiles = mentionPopoverRef.current.getDisplayFiles(); const maxIndex = Math.max(0, displayFiles.length - 1); setMentionPopover((prev) => ({ ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, maxIndex), })); return; } if (evt.key === 'ArrowUp') { evt.preventDefault(); setMentionPopover((prev) => ({ ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0), })); return; } if (evt.key === 'Enter') { evt.preventDefault(); mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex); return; } if (evt.key === 'Escape') { evt.preventDefault(); setMentionPopover((prev) => ({ ...prev, isOpen: false })); return; } } handleHistoryNavigation(evt); if (evt.key === 'Enter') { // should not trigger submit on Enter if it's composing (IME input in progress) or shift/alt(option) is pressed if (evt.shiftKey || isComposing) { // Allow line break for Shift+Enter, or during IME composition return; } if (evt.altKey) { const newValue = displayValue + '\n'; setDisplayValue(newValue); setValue(newValue); return; } evt.preventDefault(); // Handle interruption and queue logic if (handleInterruptionAndQueue()) { return; } if (canSubmit) { performSubmit(); } } }; const onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (isLoading && hasSubmittableContent) { handleInterruptionAndQueue(); return; } const canSubmit = !isLoading && (displayValue.trim() || pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) || allDroppedFiles.some((file) => !file.error && !file.isLoading)); if (canSubmit) { performSubmit(); } }; const fileInputRef = React.useRef(null); const handleFileSelect = () => { if (isFilePickerOpen) return; fileInputRef.current?.click(); }; const handleFileInputChange = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; setIsFilePickerOpen(true); const file = files[0]; const isImage = file.type.startsWith('image/'); if (isImage) { trackFileAttached('file'); if (pastedImages.length >= MAX_IMAGES_PER_MESSAGE) { console.warn(`Maximum ${MAX_IMAGES_PER_MESSAGE} images per message`); setIsFilePickerOpen(false); return; } const uniqueId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; setPastedImages((prev) => [ ...prev, { id: uniqueId, dataUrl: '', isLoading: true, error: undefined, }, ]); const reader = new FileReader(); reader.onload = async (evt) => { const dataUrl = evt.target?.result as string; if (dataUrl) { const compressedDataUrl = await compressImageDataUrl(dataUrl); setPastedImages((prev) => prev.map((img) => img.id === uniqueId ? { ...img, dataUrl: compressedDataUrl, isLoading: false, error: undefined } : img ) ); } }; reader.onerror = () => { setPastedImages((prev) => prev.map((img) => img.id === uniqueId ? { ...img, isLoading: false, error: 'Failed to read image file' } : img ) ); }; reader.readAsDataURL(file); } else { trackFileAttached('file'); const path = window.electron.getPathForFile(file); const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; setDisplayValue(newValue); setValue(newValue); } textAreaRef.current?.focus(); setIsFilePickerOpen(false); if (e.target) { e.target.value = ''; } }; const handleMentionItemSelect = (itemText: string) => { // Replace the @ mention with the file path const beforeMention = displayValue.slice(0, mentionPopover.mentionStart); const afterMention = displayValue.slice( mentionPopover.mentionStart + 1 + mentionPopover.query.length ); const newValue = `${beforeMention}${itemText}${afterMention}`; setDisplayValue(newValue); setValue(newValue); setMentionPopover((prev) => ({ ...prev, isOpen: false })); textAreaRef.current?.focus(); // Set cursor position after the inserted file path setTimeout(() => { if (textAreaRef.current) { const newCursorPosition = beforeMention.length + itemText.length; textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); } }, 0); }; const hasSubmittableContent = displayValue.trim() || pastedImages.some((img) => img.dataUrl && !img.error && !img.isLoading) || allDroppedFiles.some((file) => !file.error && !file.isLoading); const isAnyImageLoading = pastedImages.some((img) => img.isLoading); const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading); const isSubmitButtonDisabled = !hasSubmittableContent || isAnyImageLoading || isAnyDroppedFileLoading || isRecording || isTranscribing || chatState === ChatState.RestartingAgent; const getSubmitButtonTooltip = (): string => { if (isAnyImageLoading) return 'Waiting for images to save...'; if (isAnyDroppedFileLoading) return 'Processing dropped files...'; if (isRecording) return 'Recording...'; if (isTranscribing) return 'Transcribing...'; if (chatState === ChatState.RestartingAgent) return 'Restarting session...'; if (!hasSubmittableContent) return 'Type a message to send'; return 'Send'; }; // Queue management functions - no storage persistence, only in-memory const handleRemoveQueuedMessage = (messageId: string) => { setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId)); }; const handleClearQueue = () => { setQueuedMessages([]); queuePausedRef.current = false; setLastInterruption(null); }; const handleReorderMessages = (reorderedMessages: QueuedMessage[]) => { setQueuedMessages(reorderedMessages); }; const handleEditMessage = (messageId: string, newContent: string) => { setQueuedMessages((prev) => prev.map((msg) => (msg.id === messageId ? { ...msg, content: newContent } : msg)) ); }; const handleStopAndSend = (messageId: string) => { const messageToSend = queuedMessages.find((msg) => msg.id === messageId); if (!messageToSend) return; // Stop current processing and temporarily pause queue to prevent double-send if (onStop) onStop(); const wasPaused = queuePausedRef.current; queuePausedRef.current = true; // Remove the message from queue and send it immediately setQueuedMessages((prev) => prev.filter((msg) => msg.id !== messageId)); LocalMessageStorage.addMessage(messageToSend.content); handleSubmit({ msg: messageToSend.content, images: messageToSend.images }); // Restore previous pause state after a brief delay to prevent race condition setTimeout(() => { queuePausedRef.current = wasPaused; }, 100); }; const handleResumeQueue = () => { queuePausedRef.current = false; setLastInterruption(null); if (!isLoading && queuedMessages.length > 0) { const nextMessage = queuedMessages[0]; LocalMessageStorage.addMessage(nextMessage.content); handleSubmit({ msg: nextMessage.content, images: nextMessage.images }); setQueuedMessages((prev) => { const newQueue = prev.slice(1); // If queue becomes empty after processing, clear the paused state if (newQueue.length === 0) { queuePausedRef.current = false; setLastInterruption(null); } return newQueue; }); } }; return (
{/* Message Queue Display */} {queuedMessages.length > 0 && ( )} {/* Input row with inline action buttons wrapped in form */}