diff --git a/Cargo.toml.backup b/Cargo.toml.backup deleted file mode 100644 index e30ba9ee5a..0000000000 --- a/Cargo.toml.backup +++ /dev/null @@ -1,21 +0,0 @@ -[workspace] -members = ["crates/*"] -resolver = "2" - -[workspace.package] -edition = "2021" -version = "1.9.0" -authors = ["Block "] -license = "Apache-2.0" -repository = "https://github.com/block/goose" -description = "An AI agent" - -[workspace.lints.clippy] -uninlined_format_args = "allow" - -[workspace.dependencies] -rmcp = { version = "0.6.2", features = ["schemars", "auth"] } - -# Patch for Windows cross-compilation issue with crunchy -[patch.crates-io] -crunchy = { git = "https://github.com/nmathewson/crunchy", branch = "cross-compilation-fix" } diff --git a/fix_action_button.py b/fix_action_button.py deleted file mode 100644 index ba19ddcdb5..0000000000 --- a/fix_action_button.py +++ /dev/null @@ -1,46 +0,0 @@ -import re - -# Read the ChatInput.tsx file -with open('ui/desktop/src/components/ChatInput.tsx', 'r') as f: - content = f.read() - -# Find and replace the handleActionButtonClick function -old_function = ''' const handleActionButtonClick = (event: React.MouseEvent) => { - const buttonRect = event.currentTarget.getBoundingClientRect(); - - setActionPopover({ - isOpen: true, - position: { - x: buttonRect.left, - y: buttonRect.top, - }, - selectedIndex: 0, - cursorPosition: textAreaRef.current?.getBoundingClientRect ? 0 : 0, // Will be set by RichChatInput - }); - };''' - -new_function = ''' const handleActionButtonClick = (event: React.MouseEvent) => { - const buttonRect = event.currentTarget.getBoundingClientRect(); - - // Get the current cursor position from the RichChatInput - const currentCursorPosition = textAreaRef.current?.getBoundingClientRect ? displayValue.length : 0; - - setActionPopover({ - isOpen: true, - position: { - x: buttonRect.left, - y: buttonRect.top, - }, - selectedIndex: 0, - cursorPosition: currentCursorPosition, - }); - };''' - -# Replace the function -content = content.replace(old_function, new_function) - -# Write back to file -with open('ui/desktop/src/components/ChatInput.tsx', 'w') as f: - f.write(content) - -print("Updated handleActionButtonClick function to get cursor position") diff --git a/fix_action_insertion.py b/fix_action_insertion.py deleted file mode 100644 index 2356564210..0000000000 --- a/fix_action_insertion.py +++ /dev/null @@ -1,110 +0,0 @@ -import re - -# Read the ChatInput.tsx file -with open('ui/desktop/src/components/ChatInput.tsx', 'r') as f: - content = f.read() - -# Find and replace the handleActionSelect function to handle both / trigger and button click -old_function = ''' const handleActionSelect = (actionId: string) => { - const actionInfo = getActionInfo(actionId); - - // Get current cursor position from the RichChatInput - const currentValue = displayValue; - const cursorPosition = actionPopover.cursorPosition || 0; - const beforeCursor = currentValue.slice(0, cursorPosition); - const afterCursor = currentValue.slice(cursorPosition); - const lastSlashIndex = beforeCursor.lastIndexOf('/'); - - if (lastSlashIndex !== -1) { - const afterSlash = beforeCursor.slice(lastSlashIndex + 1); - // Check if we're still in the same "word" after the slash - if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) { - // Replace the /query with [Action] text - const beforeSlash = currentValue.slice(0, lastSlashIndex); - const actionText = `[${actionInfo.label}]`; - const newValue = beforeSlash + actionText + " " + afterCursor; - - setDisplayValue(newValue); - setValue(newValue); - - // Set cursor position after the action text and space - const newCursorPosition = lastSlashIndex + actionText.length + 1; - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - textAreaRef.current.focus(); - } - }, 0); - } - } - - console.log('Action selected:', actionId, 'at position:', cursorPosition); - setActionPopover(prev => ({ ...prev, isOpen: false })); - };''' - -new_function = ''' const handleActionSelect = (actionId: string) => { - const actionInfo = getActionInfo(actionId); - - // Get current cursor position from the RichChatInput - const currentValue = displayValue; - const cursorPosition = actionPopover.cursorPosition || 0; - const beforeCursor = currentValue.slice(0, cursorPosition); - const afterCursor = currentValue.slice(cursorPosition); - const lastSlashIndex = beforeCursor.lastIndexOf('/'); - - // Check if this was triggered by a / command (slash exists and no space after it) - if (lastSlashIndex !== -1) { - const afterSlash = beforeCursor.slice(lastSlashIndex + 1); - // Check if we're still in the same "word" after the slash - if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) { - // Replace the /query with [Action] text - const beforeSlash = currentValue.slice(0, lastSlashIndex); - const actionText = `[${actionInfo.label}]`; - const newValue = beforeSlash + actionText + " " + afterCursor; - - setDisplayValue(newValue); - setValue(newValue); - - // Set cursor position after the action text and space - const newCursorPosition = lastSlashIndex + actionText.length + 1; - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - textAreaRef.current.focus(); - } - }, 0); - - console.log('Action selected via slash command:', actionId, 'at position:', cursorPosition); - setActionPopover(prev => ({ ...prev, isOpen: false })); - return; - } - } - - // If not a slash command, insert action at current cursor position (button click) - const actionText = `[${actionInfo.label}]`; - const newValue = beforeCursor + actionText + " " + afterCursor; - - setDisplayValue(newValue); - setValue(newValue); - - // Set cursor position after the action text and space - const newCursorPosition = cursorPosition + actionText.length + 1; - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - textAreaRef.current.focus(); - } - }, 0); - - console.log('Action selected via button click:', actionId, 'at position:', cursorPosition); - setActionPopover(prev => ({ ...prev, isOpen: false })); - };''' - -# Replace the function -content = content.replace(old_function, new_function) - -# Write back to file -with open('ui/desktop/src/components/ChatInput.tsx', 'w') as f: - f.write(content) - -print("Updated handleActionSelect function to handle both slash commands and button clicks") diff --git a/temp_batch_issues.md b/temp_batch_issues.md deleted file mode 100644 index 1348a3558b..0000000000 --- a/temp_batch_issues.md +++ /dev/null @@ -1 +0,0 @@ -temp file \ No newline at end of file diff --git a/temp_smart_spellcheck.js b/temp_smart_spellcheck.js deleted file mode 100644 index 801102f399..0000000000 --- a/temp_smart_spellcheck.js +++ /dev/null @@ -1,156 +0,0 @@ -const fs = require('fs'); - -// Read the file -let content = fs.readFileSync('./ui/desktop/src/components/RichChatInput.tsx', 'utf8'); - -// Replace the basic spell check function with a smarter one -const oldSpellCheck = `// Simple spell checking function using browser's built-in capabilities -const checkSpelling = async (text: string): Promise<{ word: string; start: number; end: number }[]> => { - // This is a basic implementation - in a real app you might want to use a more sophisticated spell checker - const misspelledWords: { word: string; start: number; end: number }[] = []; - - // Test words - const commonMisspellings = [ - // Test words - 'sdd', 'asdf', 'qwerty', 'test', 'xyz', - // Common misspellings - 'teh', 'recieve', 'seperate', 'occured', 'neccessary', 'definately', - 'occassion', 'begining', 'tommorrow', 'accomodate', 'existance', 'maintainance', - 'alot', 'wierd', 'freind', 'thier', 'calender', 'enviroment', 'goverment', - 'independant', 'jewelery', 'liesure', 'mispell', 'noticable', 'occassionally', - 'perseverence', 'priviledge', 'recomend', 'rythm', 'sucessful', 'truely', - 'untill', 'vaccuum', 'wether', 'wich', 'writting', 'youre', 'its' - ]; - - // Split text into words while preserving positions - const words = text.split(/(\s+|[^\w\s])/); - let currentPos = 0; - - for (const word of words) { - const cleanWord = word.toLowerCase().replace(/[^\w]/g, ''); - console.log('🔍 SPELL CHECK: Checking word:', word, 'cleaned:', cleanWord); - - if (cleanWord && commonMisspellings.includes(cleanWord)) { - console.log('🔍 SPELL CHECK: Found misspelling!', cleanWord); - const start = text.indexOf(word, currentPos); - if (start !== -1) { - misspelledWords.push({ - word: word, - start: start, - end: start + word.length - }); - console.log('🔍 SPELL CHECK: Added to misspelled array:', { word, start, end: start + word.length }); - } - } - - currentPos += word.length; - } - - return misspelledWords; -};`; - -const newSpellCheck = `// Smart spell checking using browser's native capabilities and heuristics -const checkSpelling = async (text: string): Promise<{ word: string; start: number; end: number }[]> => { - const misspelledWords: { word: string; start: number; end: number }[] = []; - - // Split text into words while preserving positions - const words = text.split(/(\s+|[^\w\s])/); - let currentPos = 0; - - for (const word of words) { - const cleanWord = word.toLowerCase().replace(/[^\w]/g, ''); - - // Skip very short words, numbers, and common abbreviations - if (cleanWord.length < 3 || /^\d+$/.test(cleanWord)) { - currentPos += word.length; - continue; - } - - // Skip common programming terms, file extensions, and technical words - const technicalWords = [ - 'api', 'url', 'http', 'https', 'json', 'xml', 'css', 'html', 'js', 'ts', 'jsx', 'tsx', - 'npm', 'git', 'cli', 'ui', 'ux', 'db', 'sql', 'dev', 'prod', 'env', 'config', 'src', - 'app', 'web', 'www', 'com', 'org', 'net', 'io', 'ai', 'ml', 'gpu', 'cpu', 'ram', - 'github', 'gitlab', 'docker', 'aws', 'gcp', 'azure', 'k8s', 'oauth', 'jwt', 'cors', - 'goose', 'chat', 'llm', 'gpt', 'claude', 'openai', 'anthropic', 'react', 'node', - 'typescript', 'javascript', 'python', 'rust', 'java', 'cpp', 'csharp', 'php', 'ruby' - ]; - - if (technicalWords.includes(cleanWord)) { - currentPos += word.length; - continue; - } - - // Use heuristic-based spell checking - let isMisspelled = false; - - // Check for common patterns of misspellings - if ( - // Double letters that shouldn't be doubled - /(.)\1{2,}/.test(cleanWord) || - // Common letter swaps - /ie/.test(cleanWord) && cleanWord !== 'pie' && cleanWord !== 'tie' && cleanWord !== 'die' || - // Words ending in 'ey' that should be 'y' - /ey$/.test(cleanWord) && cleanWord.length > 4 || - // Common misspelling patterns - /seperat/.test(cleanWord) || - /reciev/.test(cleanWord) || - /occas/.test(cleanWord) || - /necess/.test(cleanWord) || - /definat/.test(cleanWord) || - /beginn/.test(cleanWord) || - /accom/.test(cleanWord) || - /existanc/.test(cleanWord) || - /maintainanc/.test(cleanWord) || - /enviroment/.test(cleanWord) || - /goverment/.test(cleanWord) || - /independant/.test(cleanWord) || - /priviledge/.test(cleanWord) || - /sucessful/.test(cleanWord) || - /untill/.test(cleanWord) || - /wether/.test(cleanWord) && cleanWord !== 'whether' || - // Test words for debugging - cleanWord === 'sdd' || cleanWord === 'asdf' || cleanWord === 'qwerty' || - cleanWord === 'teh' || cleanWord === 'alot' || cleanWord === 'wierd' || - cleanWord === 'freind' || cleanWord === 'thier' || cleanWord === 'calender' - ) { - isMisspelled = true; - } - - // Additional check: words with unusual letter combinations - if (!isMisspelled && cleanWord.length > 4) { - // Check for unusual consonant clusters or vowel patterns - if ( - /[bcdfgjklmnpqrstvwxz]{4,}/.test(cleanWord) || // Too many consonants - /[aeiou]{4,}/.test(cleanWord) || // Too many vowels - /q(?!u)/.test(cleanWord) || // Q not followed by U - /[xyz]{2,}/.test(cleanWord) // Multiple x, y, or z - ) { - isMisspelled = true; - } - } - - if (isMisspelled) { - console.log('🔍 SPELL CHECK: Found misspelling!', cleanWord); - const start = text.indexOf(word, currentPos); - if (start !== -1) { - misspelledWords.push({ - word: word, - start: start, - end: start + word.length - }); - console.log('🔍 SPELL CHECK: Added to misspelled array:', { word, start, end: start + word.length }); - } - } - - currentPos += word.length; - } - - return misspelledWords; -};`; - -content = content.replace(oldSpellCheck, newSpellCheck); - -// Write back to file -fs.writeFileSync('./ui/desktop/src/components/RichChatInput.tsx', content); -console.log('Replaced basic spell check with smart heuristic-based spell checker'); diff --git a/temp_test_typo.js b/temp_test_typo.js deleted file mode 100644 index e518b0c033..0000000000 --- a/temp_test_typo.js +++ /dev/null @@ -1,21 +0,0 @@ -// Test if typo-js is working correctly -const Typo = require('typo-js'); - -console.log('Testing Typo.js...'); -console.log('Typo constructor:', typeof Typo); - -try { - // Try to create a basic spell checker - const checker = new Typo('en_US'); - console.log('Basic checker created:', !!checker); - - // Test some words - const testWords = ['hello', 'recieve', 'seperate', 'test']; - testWords.forEach(word => { - const isCorrect = checker.check(word); - console.log(`Word "${word}": ${isCorrect ? 'CORRECT' : 'MISSPELLED'}`); - }); - -} catch (error) { - console.error('Error creating Typo checker:', error); -} diff --git a/ui/desktop/src/components/ChatInput.tsx.backup b/ui/desktop/src/components/ChatInput.tsx.backup deleted file mode 100644 index b6e99c330c..0000000000 --- a/ui/desktop/src/components/ChatInput.tsx.backup +++ /dev/null @@ -1,1835 +0,0 @@ -import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react'; -import { FolderKey, 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, Action } from './icons'; -import { ChatState } from '../types/chatState'; -import debounce from 'lodash/debounce'; -import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { Message } from '../types/message'; -import { DirSwitcher } from './bottom_menu/DirSwitcher'; -import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; -import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection'; -import { AlertType, useAlerts } from './alerts'; -import { useConfig } from './ConfigContext'; -import { useModelAndProvider } from './ModelAndProviderContext'; -import { useWhisper } from '../hooks/useWhisper'; -import { WaveformVisualizer } from './WaveformVisualizer'; -import { toastError } from '../toasts'; -import MentionPopover, { FileItemWithMatch } from './MentionPopover'; -import ActionPopover from './ActionPopover'; -import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react'; -import { useDictationSettings } from '../hooks/useDictationSettings'; -import { useContextManager } from './context_management/ContextManager'; -import { useChatContext } from '../contexts/ChatContext'; -import { COST_TRACKING_ENABLED } from '../updates'; -import { CostTracker } from './bottom_menu/CostTracker'; -import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; -import { RichChatInput, RichChatInputRef } from './RichChatInput'; -import { Recipe } from '../recipe'; -import MessageQueue from './MessageQueue'; -import { detectInterruption } from '../utils/interruptionDetector'; -import { getApiUrl } from '../config'; - -interface QueuedMessage { - id: string; - content: string; - timestamp: number; -} - -interface PastedImage { - id: string; - dataUrl: string; // For immediate preview - filePath?: string; // Path on filesystem after saving - isLoading: boolean; - error?: string; -} - -// Constants for image handling -const MAX_IMAGES_PER_MESSAGE = 5; -const MAX_IMAGE_SIZE_MB = 5; - -// 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 - -interface ModelLimit { - pattern: string; - context_limit: number; -} - -interface ChatInputProps { - sessionId: string | null; - handleSubmit: (e: React.FormEvent) => void; - chatState: ChatState; - onStop?: () => void; - commandHistory?: string[]; // Current chat's message history - initialValue?: string; - droppedFiles?: DroppedFile[]; - onFilesProcessed?: () => void; // Callback to clear dropped files after processing - setView: (view: View) => void; - numTokens?: number; - inputTokens?: number; - outputTokens?: number; - messages?: Message[]; - setMessages: (messages: Message[]) => void; - sessionCosts?: { - [key: string]: { - inputTokens: number; - outputTokens: number; - totalCost: number; - }; - }; - setIsGoosehintsModalOpen?: (isOpen: boolean) => void; - disableAnimation?: boolean; - recipeConfig?: Recipe | null; - recipeAccepted?: boolean; - initialPrompt?: string; - toolCount: number; - autoSubmit: boolean; - append?: (message: Message) => void; - isExtensionsLoading?: boolean; -} - -export default function ChatInput({ - sessionId, - handleSubmit, - chatState = ChatState.Idle, - onStop, - commandHistory = [], - initialValue = '', - droppedFiles = [], - onFilesProcessed, - setView, - numTokens, - inputTokens, - outputTokens, - messages = [], - setMessages, - disableAnimation = false, - sessionCosts, - setIsGoosehintsModalOpen, - recipeConfig, - recipeAccepted, - initialPrompt, - toolCount, - autoSubmit = false, - append, - isExtensionsLoading = false, -}: ChatInputProps) { - const [_value, setValue] = useState(initialValue); - const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback - const [isFocused, setIsFocused] = useState(false); - const [pastedImages, setPastedImages] = useState([]); - - // 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 { isCompacting, handleManualCompaction } = useContextManager(); - const { getProviders, read } = useConfig(); - const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); - const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); - const [isTokenLimitLoaded, setIsTokenLimitLoaded] = useState(false); - const [autoCompactThreshold, setAutoCompactThreshold] = useState(0.8); // Default to 80% - - // Draft functionality - get chat context and global draft context - // We need to handle the case where ChatInput is used without ChatProvider (e.g., in Hub) - const chatContext = useChatContext(); // This should always be available now - const agentIsReady = chatContext === null || chatContext.agentWaitingMessage === null; - const draftLoadedRef = useRef(false); - - // Debug logging for draft context - useEffect(() => { - // Debug logging removed - draft functionality is working correctly - }, [chatContext?.contextKey, chatContext?.draft, chatContext]); - - // 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( - new CustomEvent('submit', { - detail: { value: nextMessage.content }, - }) as unknown as React.FormEvent - ); - 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; - }>({ - isOpen: false, - position: { x: 0, y: 0 }, - query: '', - mentionStart: -1, - selectedIndex: 0, - }); - const [actionPopover, setActionPopover] = useState<{ - isOpen: boolean; - position: { x: number; y: number }; - selectedIndex: number; - cursorPosition?: number; - }>({ - isOpen: false, - position: { x: 0, y: 0 }, - selectedIndex: 0, - cursorPosition: 0, - }); - const actionPopoverRef = useRef<{ - getDisplayActions: () => any[]; - selectAction: (index: number) => void; - }>(null); - - // Action pills for visual display - const mentionPopoverRef = useRef<{ - getDisplayFiles: () => FileItemWithMatch[]; - selectFile: (index: number) => void; - }>(null); - - // 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.`, - }); - }, - }); - - // Get dictation settings to check configuration status - const { settings: dictationSettings } = useDictationSettings(); - - // Update internal value when initialValue changes - useEffect(() => { - setValue(initialValue); - setDisplayValue(initialValue); - - // Reset draft loaded flag when initialValue changes - draftLoadedRef.current = false; - - // 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 - }); - - // Reset history index when input is cleared - setHistoryIndex(-1); - setIsInGlobalHistory(false); - setHasUserTyped(false); - }, [initialValue]); // Keep only initialValue as a dependency - - // 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]); - - // Draft functionality - load draft if no initial value or recipe - useEffect(() => { - // Reset draft loaded flag when context changes - draftLoadedRef.current = false; - }, [chatContext?.contextKey]); - - useEffect(() => { - // Only load draft once and if conditions are met - if (!initialValue && !recipeConfig && !draftLoadedRef.current && chatContext) { - const draftText = chatContext.draft || ''; - - if (draftText) { - setDisplayValue(draftText); - setValue(draftText); - } - - // Always mark as loaded after checking, regardless of whether we found a draft - draftLoadedRef.current = true; - } - }, [chatContext, initialValue, recipeConfig]); - - // Save draft when user types (debounced) - const debouncedSaveDraft = useMemo( - () => - debounce((value: string) => { - if (chatContext && chatContext.setDraft) { - chatContext.setDraft(value); - } - }, 500), // Save draft after 500ms of no typing - [chatContext] - ); - - // 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 [hasUserTyped, setHasUserTyped] = useState(false); - const textAreaRef = useRef(null); - const timeoutRefsRef = useRef>>(new Set()); - const [didAutoSubmit, setDidAutoSubmit] = 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) => { - 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, isLoading: true, error: undefined } : img)) - ); - - 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(); - } - }, []); - - // Load model limits from the API - const getModelLimits = async () => { - try { - const response = await read('model-limits', false); - if (response) { - // The response is already parsed, no need for JSON.parse - return response as ModelLimit[]; - } - } catch (err) { - console.error('Error fetching model limits:', err); - } - return []; - }; - - // Helper function to find model limit using pattern matching - const findModelLimit = (modelName: string, modelLimits: ModelLimit[]): number | null => { - if (!modelName) return null; - const matchingLimit = modelLimits.find((limit) => - modelName.toLowerCase().includes(limit.pattern.toLowerCase()) - ); - return matchingLimit ? matchingLimit.context_limit : null; - }; - - // Load providers and get current model's token limit - const loadProviderDetails = async () => { - try { - // Reset token limit loaded state - setIsTokenLimitLoaded(false); - - // Get current model and provider first to avoid unnecessary provider fetches - const { model, provider } = await getCurrentModelAndProvider(); - if (!model || !provider) { - console.log('No model or provider found'); - setIsTokenLimitLoaded(true); - return; - } - - const providers = await getProviders(true); - - // Find the provider details for the current provider - const currentProvider = providers.find((p) => p.name === provider); - if (currentProvider?.metadata?.known_models) { - // Find the model's token limit from the backend response - const modelConfig = currentProvider.metadata.known_models.find((m) => m.name === model); - if (modelConfig?.context_limit) { - setTokenLimit(modelConfig.context_limit); - setIsTokenLimitLoaded(true); - return; - } - } - - // Fallback: Use pattern matching logic if no exact model match was found - const modelLimit = await getModelLimits(); - const fallbackLimit = findModelLimit(model as string, modelLimit); - if (fallbackLimit !== null) { - setTokenLimit(fallbackLimit); - setIsTokenLimitLoaded(true); - return; - } - - // If no match found, use the default model limit - 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 - useEffect(() => { - loadProviderDetails(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentModel, currentProvider]); - - // Load auto-compact threshold - const loadAutoCompactThreshold = useCallback(async () => { - try { - const secretKey = await window.electron.getSecretKey(); - const response = await fetch(getApiUrl('/config/read'), { - method: 'POST', - headers: { - 'X-Secret-Key': secretKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - key: 'GOOSE_AUTO_COMPACT_THRESHOLD', - is_secret: false, - }), - }); - if (response.ok) { - const data = await response.json(); - console.log('Loaded auto-compact threshold from config:', data); - if (data !== undefined && data !== null) { - setAutoCompactThreshold(data); - console.log('Set auto-compact threshold to:', data); - } - } else { - console.error('Failed to fetch auto-compact threshold, status:', response.status); - } - } catch (err) { - console.error('Error fetching auto-compact threshold:', err); - } - }, []); - - useEffect(() => { - loadAutoCompactThreshold(); - }, [loadAutoCompactThreshold]); - - // Listen for threshold change events from AlertBox - useEffect(() => { - const handleThresholdChange = (event: CustomEvent<{ threshold: number }>) => { - setAutoCompactThreshold(event.detail.threshold); - }; - - // Type assertion to handle the mismatch between CustomEvent and EventListener - const eventListener = handleThresholdChange as (event: globalThis.Event) => void; - window.addEventListener('autoCompactThresholdChanged', eventListener); - - return () => { - window.removeEventListener('autoCompactThresholdChanged', eventListener); - }; - }, []); - - // Handle tool count alerts and token usage - useEffect(() => { - clearAlerts(); - - // Show alert when either there is registered token usage, or we know the limit - if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { - // in these conditions we want it to be present but disabled - const compactButtonDisabled = !numTokens || isCompacting; - - addAlert({ - type: AlertType.Info, - message: 'Context window', - progress: { - current: numTokens || 0, - total: tokenLimit, - }, - showCompactButton: true, - compactButtonDisabled, - onCompact: () => { - // Hide the alert popup by dispatching a custom event that the popover can listen to - // Importantly, this leaves the alert so the dot still shows up, but hides the popover - window.dispatchEvent(new CustomEvent('hide-alert-popover')); - handleManualCompaction(messages, setMessages, append); - }, - compactIcon: , - autoCompactThreshold: autoCompactThreshold, - }); - } - - // 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 - }, [ - numTokens, - toolCount, - tokenLimit, - isTokenLimitLoaded, - addAlert, - isCompacting, - clearAlerts, - autoCompactThreshold, - ]); - - // Cleanup effect for component unmount - prevent memory leaks - useEffect(() => { - return () => { - // Clear any pending timeouts from image processing - setPastedImages((currentImages) => { - currentImages.forEach((img) => { - if (img.filePath) { - try { - window.electron.deleteTempFile(img.filePath); - } catch (error) { - console.error('Error deleting temp file:', error); - } - } - }); - 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 debouncedAutosize = useMemo( - () => - debounce((element: HTMLElement) => { - element.style.height = '0px'; // Reset height - const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 50), - [maxHeight] - ); - - useEffect(() => { - if (textAreaRef.current) { - const element = (textAreaRef.current as any).contentRef?.current; if (element) { debouncedAutosize(element); } - } - }, [debouncedAutosize, displayValue]); - - // Reset textarea height when displayValue is empty - useEffect(() => { - if (textAreaRef.current && displayValue === '') { - const element = (textAreaRef.current as any)?.contentRef?.current; if (element && element.style) { element.style.height = 'auto'; } - } - }, [displayValue]); - - // const handleChange = (evt: React.ChangeEvent) => { - // const val = evt.target.value; - // const cursorPosition = evt.target.selectionStart; - // - // setDisplayValue(val); // Update display immediately - // updateValue(val); // Update actual value immediately for better responsiveness - // debouncedSaveDraft(val); // Save draft with debounce - // // Mark that the user has typed something - // setHasUserTyped(true); - // - // // Check for @ mention - // checkForMention(val, cursorPosition, evt.target); - // }; - - const checkForMention = (text: string, cursorPosition: number, textArea: any) => { - // Find the last @ and / before the cursor - const beforeCursor = text.slice(0, cursorPosition); - const lastAtIndex = beforeCursor.lastIndexOf('@'); - const lastSlashIndex = beforeCursor.lastIndexOf('/'); - - // Determine which symbol is closer to cursor - const isSlashTrigger = lastSlashIndex > lastAtIndex; - const triggerIndex = isSlashTrigger ? lastSlashIndex : lastAtIndex; - - if (triggerIndex === -1) { - // No trigger symbol found, close both popovers - setMentionPopover((prev) => ({ ...prev, isOpen: false })); - setActionPopover((prev) => ({ ...prev, isOpen: false })); - return; - } - - // Check if there's a space between trigger symbol and cursor (which would end the trigger) - const afterTrigger = beforeCursor.slice(triggerIndex + 1); - if (afterTrigger.includes(' ') || afterTrigger.includes('\n')) { - setMentionPopover((prev) => ({ ...prev, isOpen: false })); - setActionPopover((prev) => ({ ...prev, isOpen: false })); - return; - } - - // Calculate position for the popover - position it above the chat input - const textAreaRect = textArea.getBoundingClientRect(); - - if (isSlashTrigger) { - // Open action popover for / trigger - setMentionPopover((prev) => ({ ...prev, isOpen: false })); - setActionPopover({ - isOpen: true, - position: { - x: textAreaRect.left, - y: textAreaRect.top, - }, - selectedIndex: 0, - cursorPosition: cursorPosition, - }); - } else { - // Open mention popover for @ trigger (existing functionality) - setActionPopover((prev) => ({ ...prev, isOpen: false })); - setMentionPopover((prev) => ({ - ...prev, - isOpen: true, - position: { - x: textAreaRect.left, - y: textAreaRect.top, - }, - query: afterTrigger, - mentionStart: triggerIndex, - selectedIndex: 0, - })); - } - }; - - 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) { - // 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)}`; - newImages.push({ - 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 5 seconds with cleanup tracking - const timeoutId = setTimeout(() => { - setPastedImages((prev) => prev.filter((img) => img.id !== errorId)); - timeoutRefsRef.current.delete(timeoutId); - }, 5000); - timeoutRefsRef.current.add(timeoutId); - - continue; - } - - 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) { - // Update the image with the data URL - setPastedImages((prev) => - prev.map((img) => (img.id === imageId ? { ...img, dataUrl, isLoading: true } : img)) - ); - - 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.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?.(); - debouncedSaveDraft.cancel?.(); - }; - }, [debouncedAutosize, debouncedSaveDraft]); - - // 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); - } - }; - - // Helper function to handle interruption and queue logic when loading - const handleInterruptionAndQueue = () => { - if (!isLoading || !displayValue.trim()) { - return false; // Return false if no action was taken - } - - 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 = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 9), - content: displayValue.trim(), - timestamp: Date.now(), - }; - - // Add the interruption message to the front of the queue so it gets sent first - setQueuedMessages((prev) => [interruptionMessage, ...prev]); - - setDisplayValue(''); - setValue(''); - return true; // Return true if interruption was handled - } - - const newMessage = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 9), - content: displayValue.trim(), - timestamp: Date.now(), - }; - 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; - }); - setDisplayValue(''); - setValue(''); - return true; // Return true if message was queued - }; - - const canSubmit = - !isLoading && - !isCompacting && - agentIsReady && - (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || - allDroppedFiles.some((file) => !file.error && !file.isLoading)); - - const performSubmit = useCallback( - (text?: string) => { - const validPastedImageFilesPaths = pastedImages - .filter((img) => img.filePath && !img.error && !img.isLoading) - .map((img) => img.filePath as string); - // Get paths from all dropped files (both parent and local) - const droppedFilePaths = allDroppedFiles - .filter((file) => !file.error && !file.isLoading) - .map((file) => file.path); - - let textToSend = text ?? displayValue.trim(); - - // Combine pasted images and dropped files - const allFilePaths = [...validPastedImageFilesPaths, ...droppedFilePaths]; - if (allFilePaths.length > 0) { - const pathsString = allFilePaths.join(' '); - textToSend = textToSend ? `${textToSend} ${pathsString}` : pathsString; - } - - if (textToSend) { - if (displayValue.trim()) { - LocalMessageStorage.addMessage(displayValue); - } else if (allFilePaths.length > 0) { - LocalMessageStorage.addMessage(allFilePaths.join(' ')); - } - - handleSubmit( - new CustomEvent('submit', { detail: { value: textToSend } }) as unknown as React.FormEvent - ); - - // 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); - } - - setDisplayValue(''); - setValue(''); - setPastedImages([]); - setHistoryIndex(-1); - setSavedInput(''); - setIsInGlobalHistory(false); - setHasUserTyped(false); - - // Clear draft when message is sent - if (chatContext && chatContext.clearDraft) { - chatContext.clearDraft(); - } - - // Clear selected actions when message is sent - // Actions cleared when message sent - - // Clear both parent and local dropped files after processing - if (onFilesProcessed && droppedFiles.length > 0) { - onFilesProcessed(); - } - if (localDroppedFiles.length > 0) { - setLocalDroppedFiles([]); - } - } - }, - [ - allDroppedFiles, - chatContext, - displayValue, - droppedFiles.length, - handleSubmit, - lastInterruption, - localDroppedFiles.length, - onFilesProcessed, - pastedImages, - setLocalDroppedFiles, - ] - ); - - useEffect(() => { - if (!!autoSubmit && !didAutoSubmit) { - setDidAutoSubmit(true); - performSubmit(initialValue); - } - }, [autoSubmit, didAutoSubmit, initialValue, performSubmit]); - - const handleKeyDown = (evt: React.KeyboardEvent) => { - // If mention popover is open, handle arrow keys and enter - 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; - } - } - - // 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(); - - // Handle interruption and queue logic - if (handleInterruptionAndQueue()) { - return; - } - - if (canSubmit) { - performSubmit(); - } - } - }; - - const onFormSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const canSubmit = - !isLoading && - !isCompacting && - agentIsReady && - (displayValue.trim() || - pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || - allDroppedFiles.some((file) => !file.error && !file.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 handleMentionFileSelect = (filePath: string) => { - console.log('📁 handleMentionFileSelect called with:', filePath); - - // Extract just the filename from the full path for the pill - const fileName = filePath.split('/').pop() || filePath; - console.log('📁 Extracted filename:', fileName); - - // Create @filename format for pill detection - const mentionText = `@${fileName}`; - console.log('📁 Creating mention text:', mentionText); - - // Replace the @ mention with @filename format - const beforeMention = displayValue.slice(0, mentionPopover.mentionStart); - const afterMention = displayValue.slice( - mentionPopover.mentionStart + 1 + mentionPopover.query.length - ); - const newValue = `${beforeMention}${mentionText} ${afterMention}`; - - console.log('📁 New value will be:', newValue); - - setDisplayValue(newValue); - setValue(newValue); - setMentionPopover((prev) => ({ ...prev, isOpen: false })); - textAreaRef.current?.focus(); - - // Set cursor position after the inserted mention and space - const newCursorPosition = beforeMention.length + mentionText.length + 1; - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - textAreaRef.current.focus(); - } - }, 0); - }; - - const handleActionButtonClick = (event: React.MouseEvent) => { - const buttonRect = event.currentTarget.getBoundingClientRect(); - - setActionPopover({ - isOpen: true, - position: { - x: buttonRect.left, - y: buttonRect.top, - }, - selectedIndex: 0, - cursorPosition: textAreaRef.current?.getBoundingClientRect ? 0 : 0, // Will be set by RichChatInput - }); - }; - - // Helper function to get action info - const getActionInfo = (actionId: string) => { - const actionMap = { - 'quick-task': { label: 'Quick Task', icon: }, - 'generate-code': { label: 'Generate Code', icon: }, - 'create-document': { label: 'Create Document', icon: }, - 'search-files': { label: 'Search Files', icon: }, - 'run-command': { label: 'Run Command', icon: }, - 'settings': { label: 'Settings', icon: }, - }; - return actionMap[actionId as keyof typeof actionMap] || { label: actionId, icon: }; - }; - - const handleActionSelect = (actionId: string) => { - const actionInfo = getActionInfo(actionId); - - // Get current cursor position from the RichChatInput - const currentValue = displayValue; - const cursorPosition = actionPopover.cursorPosition || 0; - const beforeCursor = currentValue.slice(0, cursorPosition); - const afterCursor = currentValue.slice(cursorPosition); - const lastSlashIndex = beforeCursor.lastIndexOf('/'); - - if (lastSlashIndex !== -1) { - const afterSlash = beforeCursor.slice(lastSlashIndex + 1); - // Check if we're still in the same "word" after the slash - if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) { - // Replace the /query with [Action] text - const beforeSlash = currentValue.slice(0, lastSlashIndex); - const actionText = `[${actionInfo.label}]`; - const newValue = beforeSlash + actionText + " " + afterCursor; - - setDisplayValue(newValue); - setValue(newValue); - - // Set cursor position after the action text and space - const newCursorPosition = lastSlashIndex + actionText.length + 1; - setTimeout(() => { - if (textAreaRef.current) { - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - textAreaRef.current.focus(); - } - }, 0); - } - } - - console.log('Action selected:', actionId, 'at position:', cursorPosition); - setActionPopover(prev => ({ ...prev, isOpen: false })); - }; - - - const hasSubmittableContent = - displayValue.trim() || - pastedImages.some((img) => img.filePath && !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 || - isCompacting || - !agentIsReady || - isExtensionsLoading; - - const isUserInputDisabled = - isAnyImageLoading || - isAnyDroppedFileLoading || - isRecording || - isTranscribing || - isCompacting || - !agentIsReady || - isExtensionsLoading; - - // 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( - new CustomEvent('submit', { - detail: { value: messageToSend.content }, - }) as unknown as React.FormEvent - ); - - // 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( - new CustomEvent('submit', { - detail: { value: nextMessage.content }, - }) as unknown as React.FormEvent - ); - 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 */} -
-
- - - { - setDisplayValue(newValue); - updateValue(newValue); - debouncedSaveDraft(newValue); - setHasUserTyped(true); - - // Check for @ mention and / action triggers - if (cursorPos !== undefined) { - const syntheticTarget = { - getBoundingClientRect: () => textAreaRef.current?.getBoundingClientRect?.() || new DOMRect(), - selectionStart: cursorPos, - selectionEnd: cursorPos, - value: newValue, - }; - checkForMention(newValue, cursorPos, syntheticTarget as HTMLTextAreaElement); - } - }} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - ref={textAreaRef} - rows={1} - disabled={isUserInputDisabled} - style={{ - maxHeight: `${maxHeight}px`, - overflowY: 'auto', - opacity: isRecording ? 0 : 1, - }} - className="w-full outline-none border-none focus:ring-0 bg-transparent px-3 pt-3 pb-1.5 pr-20 text-sm resize-none text-textStandard placeholder:text-textPlaceholder" - /> - {isRecording && ( -
- -
- )} -
- - {/* Inline action buttons on the right */} -
- {/* Microphone button - show only if dictation is enabled */} - {dictationSettings?.enabled && ( - <> - {!canUseDictation ? ( - - - - - - - - {dictationSettings.provider === 'openai' ? ( -

- OpenAI API key is not configured. Set it up in Settings {'>'}{' '} - Models. -

- ) : dictationSettings.provider === 'elevenlabs' ? ( -

- ElevenLabs API key is not configured. Set it up in Settings {'>'}{' '} - Chat {'>'} Voice Dictation. -

- ) : dictationSettings.provider === null ? ( -

- Dictation is not configured. Configure it in Settings {'>'}{' '} - Chat {'>'} Voice Dictation. -

- ) : ( -

Dictation provider is not properly configured.

- )} -
-
- ) : ( - - )} - - )} - - {/* Send/Stop button */} - {isLoading ? ( - - ) : ( - - - - - - - -

- {isExtensionsLoading - ? 'Loading extensions...' - : isCompacting - ? 'Compacting conversation...' - : isAnyImageLoading - ? 'Waiting for images to save...' - : isAnyDroppedFileLoading - ? 'Processing dropped files...' - : isRecording - ? 'Recording...' - : isTranscribing - ? 'Transcribing...' - : (chatContext?.agentWaitingMessage ?? 'Send')} -

-
-
- )} - - {/* Recording/transcribing status indicator - positioned above the button row */} - {(isRecording || isTranscribing) && ( -
- {isTranscribing ? ( - - - Transcribing... - - ) : ( - 20 ? 'text-orange-500' : 'text-textSubtle'}`} - > - - {Math.floor(recordingDuration)}s • ~{estimatedSize.toFixed(1)}MB - {estimatedSize > 20 && (near 25MB limit)} - - )} -
- )} -
-
- - {/* Combined files and images preview */} - {(pastedImages.length > 0 || allDroppedFiles.length > 0) && ( -
- {/* Render pasted images first */} - {pastedImages.map((img) => ( -
- {img.dataUrl && ( - {`Pasted - )} - {img.isLoading && ( -
-
-
- )} - {img.error && !img.isLoading && ( -
-

- {img.error.substring(0, 50)} -

- {img.dataUrl && ( - - )} -
- )} - {!img.isLoading && ( - - )} -
- ))} - - {/* Render dropped files after pasted images */} - {allDroppedFiles.map((file) => ( -
- {file.isImage ? ( - // Image preview -
- {file.dataUrl && ( - {file.name} - )} - {file.isLoading && ( -
-
-
- )} - {file.error && !file.isLoading && ( -
-

- {file.error.substring(0, 30)} -

-
- )} -
- ) : ( - // File box preview -
-
- {file.name.split('.').pop()?.toUpperCase() || 'FILE'} -
-
-

- {file.name} -

-

{file.type || 'Unknown type'}

-
-
- )} - {!file.isLoading && ( - - )} -
- ))} -
- )} - - {/* Secondary actions and controls row below input */} -
- {/* Directory path */} - -
- - {/* Action button */} - - - - - Quick Actions - -
- - {/* Attach button */} - - - - - Attach file or directory - -
- - {/* Model selector, mode selector, alerts, summarize button */} -
- {/* Cost Tracker */} - {COST_TRACKING_ENABLED && ( - <> - - - )} - -
- 0} - /> -
-
-
- -
- - - - - Configure goosehints - -
- - setMentionPopover((prev) => ({ ...prev, isOpen: false }))} - onSelect={handleMentionFileSelect} - position={mentionPopover.position} - query={mentionPopover.query} - selectedIndex={mentionPopover.selectedIndex} - onSelectedIndexChange={(index) => - setMentionPopover((prev) => ({ ...prev, selectedIndex: index })) - } - /> - - setActionPopover((prev) => ({ ...prev, isOpen: false }))} - onSelect={handleActionSelect} - position={actionPopover.position} - selectedIndex={actionPopover.selectedIndex} - onSelectedIndexChange={(index) => - setActionPopover((prev) => ({ ...prev, selectedIndex: index })) - } - /> -
-
- ); -} diff --git a/ui/desktop/src/components/RichChatInput.tsx.backup b/ui/desktop/src/components/RichChatInput.tsx.backup deleted file mode 100644 index cf965ccc14..0000000000 --- a/ui/desktop/src/components/RichChatInput.tsx.backup +++ /dev/null @@ -1,504 +0,0 @@ -import React, { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; -import { ActionPill } from './ActionPill'; -import MentionPill from './MentionPill'; -import { Zap, Code, FileText, Search, Play, Settings } from 'lucide-react'; - -interface RichChatInputProps { - value: string; - onChange: (value: string, cursorPos?: number) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - onPaste?: (e: React.ClipboardEvent) => void; - onFocus?: () => void; - onBlur?: () => void; - onCompositionStart?: () => void; - onCompositionEnd?: () => void; - placeholder?: string; - disabled?: boolean; - className?: string; - style?: React.CSSProperties; - autoFocus?: boolean; - 'data-testid'?: string; - rows?: number; -} - -// Action mapping for pill display -const ACTION_MAP = { - 'quick-task': { label: 'Quick Task', icon: }, - 'generate-code': { label: 'Generate Code', icon: }, - 'create-document': { label: 'Create Document', icon: }, - 'search-files': { label: 'Search Files', icon: }, - 'run-command': { label: 'Run Command', icon: }, - 'settings': { label: 'Settings', icon: }, -}; - -export interface RichChatInputRef { - focus: () => void; - blur: () => void; - setSelectionRange: (start: number, end: number) => void; - getBoundingClientRect: () => DOMRect; -} - -export const RichChatInput = forwardRef(({ - value, - onChange, - onKeyDown, - onPaste, - onFocus, - onBlur, - onCompositionStart, - onCompositionEnd, - placeholder, - disabled, - className, - style, - autoFocus, - 'data-testid': testId, - rows = 1, -}, ref) => { - const hiddenTextareaRef = useRef(null); - const displayRef = useRef(null); - const [isFocused, setIsFocused] = useState(false); - const [cursorPosition, setCursorPosition] = useState(0); - - // Expose methods to parent component - useImperativeHandle(ref, () => ({ - focus: () => hiddenTextareaRef.current?.focus(), - blur: () => hiddenTextareaRef.current?.blur(), - setSelectionRange: (start: number, end: number) => { - hiddenTextareaRef.current?.setSelectionRange(start, end); - setCursorPosition(start); - }, - getBoundingClientRect: () => { - return displayRef.current?.getBoundingClientRect() || new DOMRect(); - }, - }), []); - - // Update cursor position when selection changes - const updateCursorPosition = useCallback(() => { - if (hiddenTextareaRef.current) { - setCursorPosition(hiddenTextareaRef.current.selectionStart); - } - }, []); - - // Parse and render content with action pills and cursor - const renderContent = useCallback(() => { - // Show placeholder when there's no text content (regardless of focus state) - if (!value.trim()) { - return ( -
- - {placeholder} - - {isFocused && ( - - )} -
- ); - } - - const parts: React.ReactNode[] = []; - const actionRegex = /\[([^\]]+)\]/g; - const mentionRegex = /@([^\s]+)/g; // Match @filename patterns - let lastIndex = 0; - let match; - let keyCounter = 0; - let currentPos = 0; // Track position for cursor placement - - // Helper function to add cursor if needed - const addCursorIfNeeded = (position: number) => { - if (isFocused && cursorPosition === position) { - parts.push( - - ); - } - }; - - console.log('🎨 RichChatInput renderContent called with value:', value); - console.log('🔍 Looking for action and mention patterns with regex:', { actionRegex, mentionRegex }); - - // Find all actions and mentions, then sort by position - const allMatches = []; - - // Find all action matches - let actionMatch; - actionRegex.lastIndex = 0; // Reset regex - while ((actionMatch = actionRegex.exec(value)) !== null) { - allMatches.push({ - type: 'action', - match: actionMatch, - index: actionMatch.index, - length: actionMatch[0].length, - content: actionMatch[1] - }); - } - - // Find all mention matches - let mentionMatch; - mentionRegex.lastIndex = 0; // Reset regex - console.log('🔍 Searching for mentions in value:', value); - console.log('🔍 Using mention regex:', mentionRegex); - while ((mentionMatch = mentionRegex.exec(value)) !== null) { - console.log('📁 Found mention match:', mentionMatch); - allMatches.push({ - type: 'mention', - match: mentionMatch, - index: mentionMatch.index, - length: mentionMatch[0].length, - content: mentionMatch[1] // filename without @ - }); - } - - // Sort matches by position - allMatches.sort((a, b) => a.index - b.index); - - console.log('🔍 All matches found:', allMatches); - console.log('📊 Match breakdown:', { - actions: allMatches.filter(m => m.type === 'action').length, - mentions: allMatches.filter(m => m.type === 'mention').length, - total: allMatches.length - }); - - // Process all matches in order - for (const matchData of allMatches) { - const { type, match, index, length, content } = matchData; - console.log('✅ Found match:', { type, content, index }); - - // Add text before this match with potential cursor - const beforeMatch = value.slice(lastIndex, index); - if (beforeMatch) { - let textWithCursor = []; - for (let i = 0; i < beforeMatch.length; i++) { - if (isFocused && cursorPosition === currentPos) { - textWithCursor.push( - - ); - } - textWithCursor.push(beforeMatch[i]); - currentPos++; - } - - parts.push( - - {textWithCursor} - - ); - } - - // Add cursor before match if needed - if (isFocused && cursorPosition === currentPos) { - parts.push( - - ); - } - - if (type === 'action') { - // Handle action pills - const actionLabel = content; - const actionEntry = Object.entries(ACTION_MAP).find( - ([_, config]) => config.label === actionLabel - ); - - console.log('🏷️ Creating action pill:', { actionLabel, actionEntry }); - - if (actionEntry) { - const [actionId, config] = actionEntry; - parts.push( - handleRemoveAction(actionLabel)} - /> - ); - } else { - // If no matching action, render as text - parts.push( - - {match[0]} - - ); - } - } else if (type === 'mention') { - // Handle mention pills - const fileName = content; // filename without @ - const filePath = `@${fileName}`; // full mention text - - console.log('📁 Creating mention pill:', { fileName, filePath }); - - parts.push( - handleRemoveMention(fileName)} - /> - ); - } - - currentPos += length; - lastIndex = index + length; - } - - // Add remaining text with potential cursor - const remainingText = value.slice(lastIndex); - if (remainingText) { - let textWithCursor = []; - for (let i = 0; i < remainingText.length; i++) { - if (isFocused && cursorPosition === currentPos) { - textWithCursor.push( - - ); - } - textWithCursor.push(remainingText[i]); - currentPos++; - } - - parts.push( - - {textWithCursor} - - ); - } - - // Add cursor at the end if needed - if (isFocused && cursorPosition === currentPos) { - parts.push( - - ); - } - - return ( -
- {parts.length > 0 ? parts : ( - isFocused && ( - - ) - )} -
- ); - }, [value, isFocused, placeholder, cursorPosition]); - - const handleRemoveAction = useCallback((actionLabel: string) => { - const actionText = `[${actionLabel}]`; - const newValue = value.replace(actionText, ''); - onChange(newValue); - }, [value, onChange]); - - const handleRemoveMention = useCallback((fileName: string) => { - const mentionText = `@${fileName}`; - const newValue = value.replace(mentionText, ''); - onChange(newValue); - }, [value, onChange]); - - const handleTextareaChange = useCallback((e: React.ChangeEvent) => { - const newValue = e.target.value; - const newCursorPos = e.target.selectionStart; - - console.log('🔄 RichChatInput: onChange', { newValue, newCursorPos }); - console.log('🎨 Will trigger re-render with new value:', newValue); - onChange(newValue, newCursorPos); - setCursorPosition(newCursorPos); - }, [onChange]); - - const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent) => { - // Update cursor position on key events - setTimeout(updateCursorPosition, 0); - - // Handle backspace on action and mention pills - if (e.key === 'Backspace') { - const cursorPos = e.currentTarget.selectionStart; - const beforeCursor = value.slice(0, cursorPos); - - console.log('🔙 Backspace pressed, cursor at:', cursorPos); - console.log('🔙 Text before cursor:', beforeCursor); - - // Check if cursor is right after an action pill [Action] - const actionMatch = beforeCursor.match(/\[([^\]]+)\]$/); - if (actionMatch) { - console.log('🔙 Found action pill to remove:', actionMatch[1]); - e.preventDefault(); - handleRemoveAction(actionMatch[1]); - return; - } - - // Check if cursor is right after a mention pill @filename - const mentionMatch = beforeCursor.match(/@([^\s]+)$/); - if (mentionMatch) { - console.log('🔙 Found mention pill to remove:', mentionMatch[1]); - e.preventDefault(); - handleRemoveMention(mentionMatch[1]); - return; - } - } - - // Create a proper synthetic event that maintains all the original event properties - const syntheticEvent = { - ...e, - key: e.key, - shiftKey: e.shiftKey, - altKey: e.altKey, - ctrlKey: e.ctrlKey, - metaKey: e.metaKey, - preventDefault: () => e.preventDefault(), - stopPropagation: () => e.stopPropagation(), - currentTarget: { - ...e.currentTarget, - value: e.currentTarget.value, - selectionStart: e.currentTarget.selectionStart, - selectionEnd: e.currentTarget.selectionEnd, - getBoundingClientRect: () => displayRef.current?.getBoundingClientRect() || new DOMRect(), - }, - target: { - ...e.currentTarget, - value: e.currentTarget.value, - selectionStart: e.currentTarget.selectionStart, - selectionEnd: e.currentTarget.selectionEnd, - getBoundingClientRect: () => displayRef.current?.getBoundingClientRect() || new DOMRect(), - }, - } as any; - - onKeyDown?.(syntheticEvent); - }, [value, handleRemoveAction, onKeyDown, updateCursorPosition]); - - const handleTextareaPaste = useCallback((e: React.ClipboardEvent) => { - // Update cursor position after paste - setTimeout(updateCursorPosition, 0); - - // Create proper synthetic event - const syntheticEvent = { - ...e, - preventDefault: () => e.preventDefault(), - stopPropagation: () => e.stopPropagation(), - clipboardData: e.clipboardData, - currentTarget: displayRef.current, - target: displayRef.current, - } as any; - - onPaste?.(syntheticEvent); - }, [onPaste, updateCursorPosition]); - - const handleTextareaFocus = useCallback(() => { - setIsFocused(true); - updateCursorPosition(); - onFocus?.(); - }, [onFocus, updateCursorPosition]); - - const handleTextareaBlur = useCallback(() => { - setIsFocused(false); - onBlur?.(); - }, [onBlur]); - - // Removed handleDisplayClick - let the hidden textarea handle all mouse events naturally - - // Handle selection changes (cursor movement) - const handleSelectionChange = useCallback(() => { - if (document.activeElement === hiddenTextareaRef.current) { - updateCursorPosition(); - } - }, [updateCursorPosition]); - - // Auto-focus effect - useEffect(() => { - if (autoFocus && hiddenTextareaRef.current) { - hiddenTextareaRef.current.focus(); - } - }, [autoFocus]); - - // Listen for selection changes to update cursor position - useEffect(() => { - document.addEventListener('selectionchange', handleSelectionChange); - return () => { - document.removeEventListener('selectionchange', handleSelectionChange); - }; - }, [handleSelectionChange]); - - return ( -
- {/* Hidden textarea for actual input handling */} -