diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a785d5eeb4b1..a49382fb5b25 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1,4 +1,11 @@ -import React, { useRef, useState, useEffect, useMemo } from 'react'; +import React, { + useRef, + useState, + useEffect, + useMemo, + useImperativeHandle, + forwardRef, +} from 'react'; import { Button } from './ui/button'; import type { View } from '../App'; import Stop from './ui/Stop'; @@ -23,6 +30,13 @@ interface PastedImage { const MAX_IMAGES_PER_MESSAGE = 5; const MAX_IMAGE_SIZE_MB = 5; +export interface ChatInputRef { + navigateHistory: (direction: 'previous' | 'next') => void; + canNavigateHistory: (direction: 'previous' | 'next') => boolean; + submitPrompt: () => void; + canSend: () => boolean; +} + interface ChatInputProps { handleSubmit: (e: React.FormEvent) => void; isLoading?: boolean; @@ -46,403 +60,484 @@ interface ChatInputProps { }; } -export default function ChatInput({ - handleSubmit, - isLoading = false, - onStop, - commandHistory = [], - initialValue = '', - setView, - numTokens, - inputTokens, - outputTokens, - droppedFiles = [], - messages = [], - setMessages, - sessionCosts, -}: ChatInputProps) { - const [_value, setValue] = useState(initialValue); - const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback - const [isFocused, setIsFocused] = useState(false); - const [pastedImages, setPastedImages] = useState([]); - - // Whisper hook for voice dictation - const { - isRecording, - isTranscribing, - canUseDictation, - audioContext, - analyser, - startRecording, - stopRecording, - recordingDuration, - estimatedSize, - } = useWhisper({ - onTranscription: (text) => { - // Append transcribed text to the current input - const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text; - setDisplayValue(newValue); - setValue(newValue); - textAreaRef.current?.focus(); +const ChatInput = forwardRef( + ( + { + handleSubmit, + isLoading = false, + onStop, + commandHistory = [], + initialValue = '', + setView, + numTokens, + inputTokens, + outputTokens, + droppedFiles = [], + messages = [], + setMessages, + sessionCosts, }, - onError: (error) => { - toastError({ - title: 'Dictation Error', - msg: error.message, - }); - }, - onSizeWarning: (sizeMB) => { - toastError({ - title: 'Recording Size Warning', - msg: `Recording is ${sizeMB.toFixed(1)}MB. Maximum size is 25MB.`, + ref + ) => { + const [_value, setValue] = useState(initialValue); + const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback + const [isFocused, setIsFocused] = useState(false); + const [pastedImages, setPastedImages] = useState([]); + + // Whisper hook for voice dictation + const { + isRecording, + isTranscribing, + canUseDictation, + audioContext, + analyser, + startRecording, + stopRecording, + recordingDuration, + estimatedSize, + } = useWhisper({ + onTranscription: (text) => { + // Append transcribed text to the current input + const newValue = displayValue.trim() ? `${displayValue.trim()} ${text}` : text; + setDisplayValue(newValue); + setValue(newValue); + textAreaRef.current?.focus(); + }, + onError: (error) => { + toastError({ + title: 'Dictation Error', + msg: error.message, + }); + }, + onSizeWarning: (sizeMB) => { + toastError({ + title: 'Recording Size Warning', + msg: `Recording is ${sizeMB.toFixed(1)}MB. Maximum size is 25MB.`, + }); + }, + }); + + // Update internal value when initialValue changes + useEffect(() => { + setValue(initialValue); + setDisplayValue(initialValue); + + // Use a functional update to get the current pastedImages + // and perform cleanup. This avoids needing pastedImages in the deps. + setPastedImages((currentPastedImages) => { + currentPastedImages.forEach((img) => { + if (img.filePath) { + window.electron.deleteTempFile(img.filePath); + } + }); + return []; // Return a new empty array }); - }, - }); - - // Update internal value when initialValue changes - useEffect(() => { - setValue(initialValue); - setDisplayValue(initialValue); - - // Use a functional update to get the current pastedImages - // and perform cleanup. This avoids needing pastedImages in the deps. - setPastedImages((currentPastedImages) => { - currentPastedImages.forEach((img) => { - if (img.filePath) { - window.electron.deleteTempFile(img.filePath); + + // Reset history index when input is cleared + setHistoryIndex(-1); + setIsInGlobalHistory(false); + }, [initialValue]); // Keep only initialValue as a dependency + + // State to track if the IME is composing (i.e., in the middle of Japanese IME input) + const [isComposing, setIsComposing] = useState(false); + const [historyIndex, setHistoryIndex] = useState(-1); + const [savedInput, setSavedInput] = useState(''); + const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); + const textAreaRef = useRef(null); + const [processedFilePaths, setProcessedFilePaths] = useState([]); + + const navigateHistory = (direction: 'previous' | 'next') => { + const globalHistory = LocalMessageStorage.getRecentMessages() || []; + + if (historyIndex === -1) { + setSavedInput(displayValue || ''); + setIsInGlobalHistory(commandHistory.length === 0); + } + + const currentHistory = isInGlobalHistory ? globalHistory : commandHistory; + let newIndex = historyIndex; + let newValue = ''; + + if (direction === 'previous') { + if (newIndex < currentHistory.length - 1) { + newIndex = historyIndex + 1; + newValue = currentHistory[newIndex]; + } else if (!isInGlobalHistory && globalHistory.length > 0) { + // Only switch to global history if it contains different prompts + const hasUniqueGlobalPrompts = globalHistory.some( + (prompt) => !commandHistory.includes(prompt) + ); + if (hasUniqueGlobalPrompts) { + setIsInGlobalHistory(true); + newIndex = 0; + newValue = globalHistory[newIndex]; + } } - }); - return []; // Return a new empty array - }); + } else { + if (newIndex > 0) { + newIndex = historyIndex - 1; + newValue = currentHistory[newIndex]; + } else if (isInGlobalHistory && commandHistory.length > 0) { + setIsInGlobalHistory(false); + newIndex = commandHistory.length - 1; + newValue = commandHistory[newIndex]; + } else if (newIndex === 0) { + newIndex = -1; + newValue = savedInput; + } + } - // Reset history index when input is cleared - setHistoryIndex(-1); - setIsInGlobalHistory(false); - }, [initialValue]); // Keep only initialValue as a dependency - - // State to track if the IME is composing (i.e., in the middle of Japanese IME input) - const [isComposing, setIsComposing] = useState(false); - const [historyIndex, setHistoryIndex] = useState(-1); - const [savedInput, setSavedInput] = useState(''); - const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); - const textAreaRef = useRef(null); - const [processedFilePaths, setProcessedFilePaths] = useState([]); - - const handleRemovePastedImage = (idToRemove: string) => { - const imageToRemove = pastedImages.find((img) => img.id === idToRemove); - if (imageToRemove?.filePath) { - window.electron.deleteTempFile(imageToRemove.filePath); - } - setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove)); - }; + if (newIndex !== historyIndex) { + setHistoryIndex(newIndex); + if (newIndex === -1) { + setDisplayValue(savedInput || ''); + setValue(savedInput || ''); + } else { + setDisplayValue(newValue || ''); + setValue(newValue || ''); + } + textAreaRef.current?.focus(); - const handleRetryImageSave = async (imageId: string) => { - const imageToRetry = pastedImages.find((img) => img.id === imageId); - if (!imageToRetry || !imageToRetry.dataUrl) return; + setTimeout(() => { + window.dispatchEvent(new CustomEvent('navigation-state-changed')); + }, 10); + } + }; - // Set the image to loading state - setPastedImages((prev) => - prev.map((img) => (img.id === imageId ? { ...img, isLoading: true, error: undefined } : img)) - ); + const canNavigateHistoryDirection = (direction: 'previous' | 'next') => { + const globalHistory = LocalMessageStorage.getRecentMessages() || []; - try { - const result = await window.electron.saveDataUrlToTemp(imageToRetry.dataUrl, imageId); - setPastedImages((prev) => - prev.map((img) => - img.id === result.id - ? { ...img, filePath: result.filePath, error: result.error, isLoading: false } - : img - ) + if (historyIndex !== -1) { + const currentHistory = isInGlobalHistory ? globalHistory : commandHistory; + + if (direction === 'previous') { + if (historyIndex < currentHistory.length - 1) { + return true; + } + // Can switch to global history only if it has unique prompts + if (!isInGlobalHistory && globalHistory.length > 0) { + const hasUniqueGlobalPrompts = globalHistory.some( + (prompt) => !commandHistory.includes(prompt) + ); + return hasUniqueGlobalPrompts; + } + return false; + } else { + return ( + historyIndex > 0 || + (isInGlobalHistory && commandHistory.length > 0) || + historyIndex >= 0 + ); + } + } else { + if (direction === 'previous') { + return commandHistory.length > 0 || globalHistory.length > 0; + } else { + return false; + } + } + }; + + const canSendPrompt = () => { + const hasSubmittableContent = + !!displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading); + const isAnyImageLoading = pastedImages.some((img) => img.isLoading); + + return ( + !isLoading && hasSubmittableContent && !isAnyImageLoading && !isRecording && !isTranscribing ); - } catch (err) { - console.error('Error retrying image save:', err); + }; + + useImperativeHandle(ref, () => ({ + navigateHistory, + canNavigateHistory: canNavigateHistoryDirection, + submitPrompt: performSubmit, + canSend: canSendPrompt, + })); + + const handleRemovePastedImage = (idToRemove: string) => { + const imageToRemove = pastedImages.find((img) => img.id === idToRemove); + if (imageToRemove?.filePath) { + window.electron.deleteTempFile(imageToRemove.filePath); + } + setPastedImages((currentImages) => currentImages.filter((img) => img.id !== idToRemove)); + }; + + const handleRetryImageSave = async (imageId: string) => { + const imageToRetry = pastedImages.find((img) => img.id === imageId); + if (!imageToRetry || !imageToRetry.dataUrl) return; + + // Set the image to loading state setPastedImages((prev) => prev.map((img) => - img.id === imageId - ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } - : img + img.id === imageId ? { ...img, isLoading: true, error: undefined } : img ) ); - } - }; - useEffect(() => { - if (textAreaRef.current) { - textAreaRef.current.focus(); - } - }, []); - - const minHeight = '1rem'; - const maxHeight = 10 * 24; - - // If we have dropped files, add them to the input and update our state. - useEffect(() => { - if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) { - // Append file paths that aren't in displayValue. - const currentText = displayValue || ''; - const joinedPaths = currentText.trim() - ? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}` - : droppedFiles.join(' '); - - setDisplayValue(joinedPaths); - setValue(joinedPaths); - textAreaRef.current?.focus(); - setProcessedFilePaths(droppedFiles); - } - }, [droppedFiles, processedFilePaths, displayValue]); - - // Debounced function to update actual value - const debouncedSetValue = useMemo( - () => - debounce((value: string) => { - setValue(value); - }, 150), - [setValue] - ); - - // Debounced autosize function - const debouncedAutosize = useMemo( - () => - debounce((element: HTMLTextAreaElement) => { - element.style.height = '0px'; // Reset height - const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 150), - [maxHeight] - ); - - useEffect(() => { - if (textAreaRef.current) { - debouncedAutosize(textAreaRef.current); - } - }, [debouncedAutosize, displayValue]); - - const handleChange = (evt: React.ChangeEvent) => { - const val = evt.target.value; - setDisplayValue(val); // Update display immediately - debouncedSetValue(val); // Debounce the actual state update - }; + try { + const result = await window.electron.saveDataUrlToTemp(imageToRetry.dataUrl, imageId); + setPastedImages((prev) => + prev.map((img) => + img.id === result.id + ? { ...img, filePath: result.filePath, error: result.error, isLoading: false } + : img + ) + ); + } catch (err) { + console.error('Error retrying image save:', err); + setPastedImages((prev) => + prev.map((img) => + img.id === imageId + ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } + : img + ) + ); + } + }; + + useEffect(() => { + if (textAreaRef.current) { + textAreaRef.current.focus(); + } + }, []); + + const minHeight = '1rem'; + const maxHeight = 10 * 24; + + // If we have dropped files, add them to the input and update our state. + useEffect(() => { + if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) { + // Append file paths that aren't in displayValue. + const currentText = displayValue || ''; + const joinedPaths = currentText.trim() + ? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}` + : droppedFiles.join(' '); + + setDisplayValue(joinedPaths); + setValue(joinedPaths); + textAreaRef.current?.focus(); + setProcessedFilePaths(droppedFiles); + } + }, [droppedFiles, processedFilePaths, displayValue]); + + // Debounced function to update actual value + const debouncedSetValue = useMemo( + () => + debounce((value: string) => { + setValue(value); + }, 150), + [setValue] + ); + + // Debounced autosize function + const debouncedAutosize = useMemo( + () => + debounce((element: HTMLTextAreaElement) => { + element.style.height = '0px'; // Reset height + const scrollHeight = element.scrollHeight; + element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; + }, 150), + [maxHeight] + ); + + useEffect(() => { + if (textAreaRef.current) { + debouncedAutosize(textAreaRef.current); + } - 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.`, - }, - ]); - - // Remove the error message after 3 seconds setTimeout(() => { - setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); - }, 3000); + window.dispatchEvent(new CustomEvent('navigation-state-changed')); + }, 10); + }, [debouncedAutosize, displayValue]); - return; - } + useEffect(() => { + setTimeout(() => { + window.dispatchEvent(new CustomEvent('navigation-state-changed')); + }, 10); + }, [isLoading, pastedImages, isRecording, isTranscribing]); + + const handleChange = (evt: React.ChangeEvent) => { + const val = evt.target.value; + setDisplayValue(val); // Update display immediately + debouncedSetValue(val); // Debounce the actual state update + }; - evt.preventDefault(); + const handlePaste = async (evt: React.ClipboardEvent) => { + const files = Array.from(evt.clipboardData.files || []); + const imageFiles = files.filter((file) => file.type.startsWith('image/')); - for (const file of imageFiles) { - // Check individual file size before processing - if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { - const errorId = `error-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + 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: errorId, + id: `error-${Date.now()}`, dataUrl: '', isLoading: false, - error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, + error: `Cannot paste ${imageFiles.length} image(s). Maximum ${MAX_IMAGES_PER_MESSAGE} images per message allowed.`, }, ]); // Remove the error message after 3 seconds setTimeout(() => { - setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); + setPastedImages((prev) => prev.filter((img) => !img.id.startsWith('error-'))); }, 3000); - continue; + return; } - const reader = new FileReader(); - reader.onload = async (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - setPastedImages((prev) => [...prev, { id: imageId, dataUrl, isLoading: true }]); - - try { - const result = await window.electron.saveDataUrlToTemp(dataUrl, imageId); - setPastedImages((prev) => - prev.map((img) => - img.id === result.id - ? { ...img, filePath: result.filePath, error: result.error, isLoading: false } - : img - ) - ); - } catch (err) { - console.error('Error saving pasted image:', err); - setPastedImages((prev) => - prev.map((img) => - img.id === imageId - ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } - : img - ) - ); - } + evt.preventDefault(); + + for (const file of imageFiles) { + // Check individual file size before processing + if (file.size > MAX_IMAGE_SIZE_MB * 1024 * 1024) { + const errorId = `error-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + setPastedImages((prev) => [ + ...prev, + { + id: errorId, + dataUrl: '', + isLoading: false, + error: `Image too large (${Math.round(file.size / (1024 * 1024))}MB). Maximum ${MAX_IMAGE_SIZE_MB}MB allowed.`, + }, + ]); + + // Remove the error message after 3 seconds + setTimeout(() => { + setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); + }, 3000); + + continue; } + + const reader = new FileReader(); + reader.onload = async (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + const imageId = `img-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + setPastedImages((prev) => [...prev, { id: imageId, dataUrl, isLoading: true }]); + + try { + const result = await window.electron.saveDataUrlToTemp(dataUrl, imageId); + setPastedImages((prev) => + prev.map((img) => + img.id === result.id + ? { ...img, filePath: result.filePath, error: result.error, isLoading: false } + : img + ) + ); + } catch (err) { + console.error('Error saving pasted image:', err); + setPastedImages((prev) => + prev.map((img) => + img.id === imageId + ? { ...img, error: 'Failed to save image via Electron.', isLoading: false } + : img + ) + ); + } + } + }; + reader.readAsDataURL(file); + } + }; + + // Cleanup debounced functions on unmount + useEffect(() => { + return () => { + debouncedSetValue.cancel?.(); + debouncedAutosize.cancel?.(); }; - reader.readAsDataURL(file); - } - }; + }, [debouncedSetValue, debouncedAutosize]); - // Cleanup debounced functions on unmount - useEffect(() => { - return () => { - debouncedSetValue.cancel?.(); - debouncedAutosize.cancel?.(); + // Handlers for composition events, which are crucial for proper IME behavior + const handleCompositionStart = () => { + setIsComposing(true); }; - }, [debouncedSetValue, debouncedAutosize]); - // Handlers for composition events, which are crucial for proper IME behavior - const handleCompositionStart = () => { - setIsComposing(true); - }; + const handleCompositionEnd = () => { + setIsComposing(false); + }; - const handleCompositionEnd = () => { - setIsComposing(false); - }; + const handleHistoryNavigation = (evt: React.KeyboardEvent) => { + const isUp = evt.key === 'ArrowUp'; + const isDown = evt.key === 'ArrowDown'; - 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; - } - - 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 || ''); + if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) { + return; } - } - }; - const performSubmit = () => { - const validPastedImageFilesPaths = pastedImages - .filter((img) => img.filePath && !img.error && !img.isLoading) - .map((img) => img.filePath as string); + evt.preventDefault(); + navigateHistory(isUp ? 'previous' : 'next'); + }; - let textToSend = displayValue.trim(); + const performSubmit = () => { + const validPastedImageFilesPaths = pastedImages + .filter((img) => img.filePath && !img.error && !img.isLoading) + .map((img) => img.filePath as string); - if (validPastedImageFilesPaths.length > 0) { - const pathsString = validPastedImageFilesPaths.join(' '); - textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString; - } + let textToSend = displayValue.trim(); - if (textToSend) { - if (displayValue.trim()) { - LocalMessageStorage.addMessage(displayValue); - } else if (validPastedImageFilesPaths.length > 0) { - LocalMessageStorage.addMessage(validPastedImageFilesPaths.join(' ')); + if (validPastedImageFilesPaths.length > 0) { + const pathsString = validPastedImageFilesPaths.join(' '); + textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString; } - handleSubmit( - new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent - ); - - setDisplayValue(''); - setValue(''); - setPastedImages([]); - setHistoryIndex(-1); - setSavedInput(''); - setIsInGlobalHistory(false); - } - }; + if (textToSend) { + if (displayValue.trim()) { + LocalMessageStorage.addMessage(displayValue); + } else if (validPastedImageFilesPaths.length > 0) { + LocalMessageStorage.addMessage(validPastedImageFilesPaths.join(' ')); + } - const handleKeyDown = (evt: React.KeyboardEvent) => { - // Handle history navigation first - handleHistoryNavigation(evt); + handleSubmit( + new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent + ); - 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; + setDisplayValue(''); + setValue(''); + setPastedImages([]); + setHistoryIndex(-1); + setSavedInput(''); + setIsInGlobalHistory(false); } + }; - if (evt.altKey) { - const newValue = displayValue + '\n'; - setDisplayValue(newValue); - setValue(newValue); - return; + const handleKeyDown = (evt: React.KeyboardEvent) => { + // Handle history navigation first + 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(); + const canSubmit = + !isLoading && + (displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); + if (canSubmit) { + performSubmit(); + } } + }; - evt.preventDefault(); + const onFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); const canSubmit = !isLoading && (displayValue.trim() || @@ -450,247 +545,241 @@ export default function ChatInput({ if (canSubmit) { performSubmit(); } - } - }; + }; - const onFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const canSubmit = - !isLoading && - (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading)); - if (canSubmit) { - performSubmit(); - } - }; + const handleFileSelect = async () => { + const path = await window.electron.selectFileOrDirectory(); + if (path) { + const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; + setDisplayValue(newValue); + setValue(newValue); + textAreaRef.current?.focus(); + } + }; - const handleFileSelect = async () => { - const path = await window.electron.selectFileOrDirectory(); - if (path) { - const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; - setDisplayValue(newValue); - setValue(newValue); - textAreaRef.current?.focus(); - } - }; + const hasSubmittableContent = + displayValue.trim() || + pastedImages.some((img) => img.filePath && !img.error && !img.isLoading); + const isAnyImageLoading = pastedImages.some((img) => img.isLoading); + + return ( +
+
+
+