From 10fd72e0b5d0a135da86981ef9a2eee1773a2567 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Thu, 25 Sep 2025 09:36:52 -0400 Subject: [PATCH] debug: fix TypeScript conflicts and add extensive spell check debugging - Fix import conflicts between local and imported checkSpelling functions - Add @ts-ignore for typo-js import compatibility - Add extensive console logging throughout spell check process - Add fallback spell checker for testing if Typo.js fails - Install @types/typo-js for better TypeScript support - This should help identify why spell check highlighting isn't working --- Cargo.toml.backup | 21 + fix_action_button.py | 46 + fix_action_insertion.py | 110 + temp_smart_spellcheck.js | 156 ++ temp_test_typo.js | 21 + ui/desktop/package-lock.json | 15 + ui/desktop/package.json | 2 + ui/desktop/src/components/ChatInput.tsx | 5 +- .../src/components/ChatInput.tsx.backup | 1835 +++++++++++++++++ ui/desktop/src/components/RichChatInput.tsx | 15 +- .../src/components/RichChatInput.tsx.backup | 504 +++++ ui/desktop/src/utils/realSpellCheck.ts | 42 +- 12 files changed, 2762 insertions(+), 10 deletions(-) create mode 100644 Cargo.toml.backup create mode 100644 fix_action_button.py create mode 100644 fix_action_insertion.py create mode 100644 temp_smart_spellcheck.js create mode 100644 temp_test_typo.js create mode 100644 ui/desktop/src/components/ChatInput.tsx.backup create mode 100644 ui/desktop/src/components/RichChatInput.tsx.backup diff --git a/Cargo.toml.backup b/Cargo.toml.backup new file mode 100644 index 0000000000..e30ba9ee5a --- /dev/null +++ b/Cargo.toml.backup @@ -0,0 +1,21 @@ +[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 new file mode 100644 index 0000000000..ba19ddcdb5 --- /dev/null +++ b/fix_action_button.py @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000000..2356564210 --- /dev/null +++ b/fix_action_insertion.py @@ -0,0 +1,110 @@ +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_smart_spellcheck.js b/temp_smart_spellcheck.js new file mode 100644 index 0000000000..801102f399 --- /dev/null +++ b/temp_smart_spellcheck.js @@ -0,0 +1,156 @@ +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 new file mode 100644 index 0000000000..e518b0c033 --- /dev/null +++ b/temp_test_typo.js @@ -0,0 +1,21 @@ +// 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/package-lock.json b/ui/desktop/package-lock.json index 008375acf1..b15597be37 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -55,6 +55,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", + "typo-js": "^1.3.1", "unist-util-visit": "^5.0.0", "uuid": "^13.0.0", "zod": "^3.25.76" @@ -87,6 +88,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-syntax-highlighter": "^15.5.13", + "@types/typo-js": "^1.2.2", "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", @@ -6389,6 +6391,13 @@ "@types/node": "*" } }, + "node_modules/@types/typo-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/typo-js/-/typo-js-1.2.2.tgz", + "integrity": "sha512-AgN5IwO3EPXv2d+UE9VdoOcueCBa4ZcHHwUvx2/IXlfRvkW4Yt3g+eRHzJbkySfj9pyILRT6Zk7V6Vr4LD5Kvg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -19612,6 +19621,12 @@ "node": ">=14.17" } }, + "node_modules/typo-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz", + "integrity": "sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg==", + "license": "BSD-3-Clause" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/ui/desktop/package.json b/ui/desktop/package.json index ee19c02908..d6280ecdea 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -85,6 +85,7 @@ "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", + "typo-js": "^1.3.1", "unist-util-visit": "^5.0.0", "uuid": "^13.0.0", "zod": "^3.25.76" @@ -117,6 +118,7 @@ "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/react-syntax-highlighter": "^15.5.13", + "@types/typo-js": "^1.2.2", "@types/yauzl": "^2.10.3", "@typescript-eslint/eslint-plugin": "^8.39.1", "@typescript-eslint/parser": "^8.39.1", diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index dcdade63fd..437b8b5faf 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -1196,6 +1196,9 @@ export default function ChatInput({ 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: { @@ -1203,7 +1206,7 @@ export default function ChatInput({ y: buttonRect.top, }, selectedIndex: 0, - cursorPosition: textAreaRef.current?.getBoundingClientRect ? 0 : 0, // Will be set by RichChatInput + cursorPosition: currentCursorPosition, }); }; diff --git a/ui/desktop/src/components/ChatInput.tsx.backup b/ui/desktop/src/components/ChatInput.tsx.backup new file mode 100644 index 0000000000..b6e99c330c --- /dev/null +++ b/ui/desktop/src/components/ChatInput.tsx.backup @@ -0,0 +1,1835 @@ +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 b/ui/desktop/src/components/RichChatInput.tsx index ce4bf5509e..a3d0816f0c 100644 --- a/ui/desktop/src/components/RichChatInput.tsx +++ b/ui/desktop/src/components/RichChatInput.tsx @@ -126,10 +126,17 @@ export const RichChatInput = forwardRef(({ // Spell check the content const performSpellCheck = useCallback(async (text: string) => { - console.log('🔍 SPELL CHECK: Checking text:', text); - const misspelled = await checkSpelling(text); - console.log('🔍 SPELL CHECK: Found misspelled words:', misspelled); - setMisspelledWords(misspelled); + console.log('🔍 SPELL CHECK: Starting check for text:', text); + try { + const misspelled = await checkSpelling(text); + console.log('🔍 SPELL CHECK: Found misspelled words:', misspelled); + console.log('🔍 SPELL CHECK: Setting misspelled words state...'); + setMisspelledWords(misspelled); + console.log('🔍 SPELL CHECK: State updated successfully'); + } catch (error) { + console.error('🔍 SPELL CHECK ERROR:', error); + setMisspelledWords([]); + } }, []); // Debounced spell check diff --git a/ui/desktop/src/components/RichChatInput.tsx.backup b/ui/desktop/src/components/RichChatInput.tsx.backup new file mode 100644 index 0000000000..cf965ccc14 --- /dev/null +++ b/ui/desktop/src/components/RichChatInput.tsx.backup @@ -0,0 +1,504 @@ +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 */} +