diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index a73898968e9d..1c1423ab2c52 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -27,6 +27,14 @@ 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'; + +interface QueuedMessage { + id: string; + content: string; + timestamp: number; +} interface PastedImage { id: string; @@ -106,6 +114,14 @@ export default function ChatInput({ // 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 @@ -126,6 +142,73 @@ export default function ChatInput({ 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 }; @@ -788,6 +871,54 @@ export default function ChatInput({ } }; + // 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 performSubmit = () => { const validPastedImageFilesPaths = pastedImages .filter((img) => img.filePath && !img.error && !img.isLoading) @@ -818,6 +949,17 @@ export default function ChatInput({ 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([]); @@ -892,6 +1034,12 @@ export default function ChatInput({ } evt.preventDefault(); + + // Handle interruption and queue logic + if (handleInterruptionAndQueue()) { + return; + } + const canSubmit = !isLoading && !isLoadingCompaction && @@ -956,6 +1104,74 @@ export default function ChatInput({ const isAnyImageLoading = pastedImages.some((img) => img.isLoading); const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading); + // 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 */}
diff --git a/ui/desktop/src/components/InterruptionHandler.tsx b/ui/desktop/src/components/InterruptionHandler.tsx new file mode 100644 index 000000000000..b6e3d1f45366 --- /dev/null +++ b/ui/desktop/src/components/InterruptionHandler.tsx @@ -0,0 +1,223 @@ +import React, { useState, useEffect } from 'react'; +import { AlertTriangle, StopCircle, PauseCircle, RotateCcw, Zap, AlertCircle } from 'lucide-react'; +import { Button } from './ui/button'; +import { InterruptionMatch } from '../utils/interruptionDetector'; + +interface InterruptionHandlerProps { + match: InterruptionMatch | null; + onConfirmInterruption: () => void; + onCancelInterruption: () => void; + onRedirect?: (newMessage: string) => void; + className?: string; +} + +export const InterruptionHandler: React.FC = ({ + match, + onConfirmInterruption, + onCancelInterruption, + onRedirect, + className = '', +}) => { + const [redirectMessage, setRedirectMessage] = useState(''); + const [showRedirectInput, setShowRedirectInput] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (match) { + setIsVisible(true); + if (match.keyword.action === 'redirect') { + setShowRedirectInput(true); + } else { + setShowRedirectInput(false); + setRedirectMessage(''); + } + } else { + setIsVisible(false); + } + }, [match]); + + if (!match) { + return null; + } + + const getIcon = () => { + switch (match.keyword.action) { + case 'stop': + return ; + case 'pause': + return ; + case 'redirect': + return ; + default: + return ; + } + }; + + const getActionColor = () => { + switch (match.keyword.action) { + case 'stop': + return { + bg: 'bg-red-50 dark:bg-red-950/20', + border: 'border-red-200 dark:border-red-800/50', + text: 'text-red-800 dark:text-red-200', + accent: 'text-red-600 dark:text-red-400' + }; + case 'pause': + return { + bg: 'bg-amber-50 dark:bg-amber-950/20', + border: 'border-amber-200 dark:border-amber-800/50', + text: 'text-amber-800 dark:text-amber-200', + accent: 'text-amber-600 dark:text-amber-400' + }; + case 'redirect': + return { + bg: 'bg-blue-50 dark:bg-blue-950/20', + border: 'border-blue-200 dark:border-blue-800/50', + text: 'text-blue-800 dark:text-blue-200', + accent: 'text-blue-600 dark:text-blue-400' + }; + default: + return { + bg: 'bg-orange-50 dark:bg-orange-950/20', + border: 'border-orange-200 dark:border-orange-800/50', + text: 'text-orange-800 dark:text-orange-200', + accent: 'text-orange-600 dark:text-orange-400' + }; + } + }; + + const colors = getActionColor(); + + const handleConfirm = () => { + if (showRedirectInput && onRedirect && redirectMessage.trim()) { + onRedirect(redirectMessage.trim()); + } else { + onConfirmInterruption(); + } + }; + + const getActionTitle = () => { + switch (match.keyword.action) { + case 'stop': return 'Stop Processing'; + case 'pause': return 'Pause Processing'; + case 'redirect': return 'Redirect Processing'; + default: return 'Interrupt Processing'; + } + }; + + const getActionDescription = () => { + switch (match.keyword.action) { + case 'stop': + return 'This will immediately stop the current processing and clear any queued messages.'; + case 'pause': + return 'This will pause the current processing. Queued messages will be preserved.'; + case 'redirect': + return 'This will stop current processing and redirect to a new task.'; + default: + return 'This will interrupt the current processing.'; + } + }; + + return ( +
+
+ {/* Main card */} +
+ {/* Header */} +
+
+
+ {getIcon()} +
+
+

+ {getActionTitle()} +

+

+ Detected: "{match.matchedText}" +

+
+
+ {Math.round(match.confidence * 100)}% confident +
+
+
+ + {/* Content */} +
+
+ +

+ {getActionDescription()} +

+
+ + {/* Redirect input */} + {showRedirectInput && ( +
+ +