diff --git a/pr_description.md b/pr_description.md deleted file mode 100644 index e87ad390ef..0000000000 --- a/pr_description.md +++ /dev/null @@ -1,80 +0,0 @@ -# Rich Text Input with Action and Mention Pills - -This PR introduces a sophisticated rich text input system that replaces plain text with visual pills for actions and file mentions, creating a more intuitive and modern chat interface. - -## ๐ŸŽฏ Features - -### Visual Pills -- **Action Pills**: Blue circles with white icons for `/` commands (e.g., `[Quick Task]`) -- **Mention Pills**: Blue diamonds for `@` file references (e.g., `@filename.txt`) -- **Consistent Styling**: White pill backgrounds with blue accent icons -- **Theme Support**: Adapts to light/dark modes - -### User Experience -- **Natural Text Flow**: Pills display inline with text content -- **Text Selection**: Full support for click/drag, double-click, triple-click -- **Backspace Removal**: Delete entire pills as single units -- **Proper Cursor**: Black blinking cursor with correct positioning -- **Multi-line Support**: Cursor stays aligned on line breaks - -### Components Added -- `RichChatInput.tsx`: Main rich text input with pill rendering -- `ActionPill.tsx`: Blue circle pills for actions -- `MentionPill.tsx`: Blue diamond pills for file mentions -- `MessageContent.tsx`: Enhanced message display with pills - -## ๐Ÿ”ง Technical Implementation - -### Architecture -- **Hidden Textarea**: Handles actual input and selection -- **Visual Display**: Renders pills and cursor overlay -- **Perfect Alignment**: Synchronized font, padding, and positioning -- **Event Handling**: Proper mouse/keyboard event delegation - -### Pattern Matching -- **Actions**: `/action` โ†’ `[Action Name]` โ†’ Blue circle pill -- **Mentions**: `@filename` โ†’ Blue diamond pill -- **Mixed Content**: Supports both types inline with text - -### Accessibility -- **Keyboard Navigation**: Full keyboard support maintained -- **Screen Readers**: Proper ARIA attributes and roles -- **Selection**: Standard text selection behaviors preserved - -## ๐ŸŽจ Visual Design - -### Before -``` -Hello /quick-task and @file.txt world -``` - -### After -``` -Hello [Quick Task] and @file.txt world - โ†‘ blue circle โ†‘ blue diamond -``` - -### Message History -Pills appear in sent messages maintaining the same visual style and inline flow. - -## ๐Ÿงช Testing - -Tested extensively for: -- โœ… Action pill creation via `/` command -- โœ… File mention pills via `@` command -- โœ… Text selection and highlighting -- โœ… Cursor positioning and movement -- โœ… Backspace pill removal -- โœ… Multi-line text handling -- โœ… Light/dark theme compatibility -- โœ… Message history display - -## ๐Ÿš€ Impact - -This creates a modern chat interface similar to Slack/Discord with: -- **Improved UX**: Visual pills are easier to understand than plain text -- **Better Organization**: Clear distinction between actions and file references -- **Enhanced Workflow**: Faster recognition of commands and mentions -- **Professional Appearance**: Polished, modern interface design - -The implementation maintains full backward compatibility while significantly enhancing the user experience. diff --git a/ui/desktop/src/components/ActionPill.tsx.backup b/ui/desktop/src/components/ActionPill.tsx.backup deleted file mode 100644 index bebfb534ca..0000000000 --- a/ui/desktop/src/components/ActionPill.tsx.backup +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { X } from 'lucide-react'; - -interface ActionPillProps { - actionId: string; - label: string; - icon: React.ReactNode; - onRemove: () => void; -} - -export const ActionPill: React.FC = ({ label, icon, onRemove }) => { - return ( -
- - {icon} - {label} - - -
- ); -}; - -export default ActionPill; diff --git a/ui/desktop/src/components/ChatInput.tsx.backup b/ui/desktop/src/components/ChatInput.tsx.backup deleted file mode 100644 index 3c9ae80514..0000000000 --- a/ui/desktop/src/components/ChatInput.tsx.backup +++ /dev/null @@ -1,1844 +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 ActionPill from './ActionPill'; -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 { 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; - }>({ - isOpen: false, - position: { x: 0, y: 0 }, - selectedIndex: 0, - }); - const actionPopoverRef = useRef<{ - getDisplayActions: () => any[]; - selectAction: (index: number) => void; - }>(null); - - // Action pills for visual display - const [selectedActions, setSelectedActions] = useState>([]); - 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: HTMLTextAreaElement) => { - element.style.height = '0px'; // Reset height - const scrollHeight = element.scrollHeight; - element.style.height = Math.min(scrollHeight, maxHeight) + 'px'; - }, 50), - [maxHeight] - ); - - useEffect(() => { - if (textAreaRef.current) { - debouncedAutosize(textAreaRef.current); - } - }, [debouncedAutosize, displayValue]); - - // Reset textarea height when displayValue is empty - useEffect(() => { - if (textAreaRef.current && displayValue === '') { - textAreaRef.current.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: HTMLTextAreaElement) => { - // 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, - }); - } 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 - setSelectedActions([]); - - // 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) => { - // Replace the @ mention with the file path - const beforeMention = displayValue.slice(0, mentionPopover.mentionStart); - const afterMention = displayValue.slice( - mentionPopover.mentionStart + 1 + mentionPopover.query.length - ); - const newValue = `${beforeMention}${filePath}${afterMention}`; - - setDisplayValue(newValue); - setValue(newValue); - setMentionPopover((prev) => ({ ...prev, isOpen: false })); - textAreaRef.current?.focus(); - - // Set cursor position after the inserted file path - setTimeout(() => { - if (textAreaRef.current) { - const newCursorPosition = beforeMention.length + filePath.length; - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - } - }, 0); - }; - - const handleActionButtonClick = (event: React.MouseEvent) => { - const buttonRect = event.currentTarget.getBoundingClientRect(); - - setActionPopover({ - isOpen: true, - position: { - x: buttonRect.left, - y: buttonRect.top, - }, - selectedIndex: 0, - }); - }; - - // 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); - - // Replace / with [Action] text inline - const currentValue = displayValue; - const cursorPosition = textAreaRef.current?.selectionStart || 0; - const beforeCursor = currentValue.slice(0, cursorPosition); - const lastSlashIndex = beforeCursor.lastIndexOf('/'); - - if (lastSlashIndex !== -1) { - const afterSlash = beforeCursor.slice(lastSlashIndex + 1); - if (!afterSlash.includes(' ') && !afterSlash.includes('\n')) { - // Replace / with [Action] text - const beforeSlash = currentValue.slice(0, lastSlashIndex); - const afterCursor = currentValue.slice(cursorPosition); - const actionText = `[${actionInfo.label}]`; - const newValue = beforeSlash + actionText + afterCursor; - - setDisplayValue(newValue); - setValue(newValue); - - // Track selected action for pill display - const newAction = { - id: Date.now().toString() + Math.random().toString(36).substr(2, 9), - actionId, - label: actionInfo.label, - icon: actionInfo.icon, - }; - - setSelectedActions(prev => [...prev, newAction]); - - // Set cursor position after the action text - setTimeout(() => { - if (textAreaRef.current) { - const newCursorPosition = lastSlashIndex + actionText.length; - textAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition); - } - }, 0); - } - } - - console.log('Action selected:', actionId); - setActionPopover(prev => ({ ...prev, isOpen: false })); - }; - - const handleRemoveAction = (actionId: string) => { - const actionToRemove = selectedActions.find(action => action.id === actionId); - if (actionToRemove) { - // Remove [Action] text from displayValue - const actionText = `[${actionToRemove.label}]`; - const newValue = displayValue.replace(actionText, ''); - setDisplayValue(newValue); - setValue(newValue); - - // Remove from selected actions - setSelectedActions(prev => prev.filter(action => action.id !== actionId)); - } - }; - - 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 */} -
-
- {/* Selected Actions Pills */} - {selectedActions.length > 0 && ( -
- Selected: - {selectedActions.map((action) => ( - handleRemoveAction(action.id)} - /> - ))} -
- )} - -