From 816b2052e2215d58b39269970d38f0374741d132 Mon Sep 17 00:00:00 2001 From: spencrmartin Date: Tue, 19 Aug 2025 09:51:08 -0400 Subject: [PATCH 01/17] feat: Add message queue system with interruption handling - Add MessageQueue component with drag-and-drop reordering - Add interruption detection for natural language commands - Add queue pause/resume functionality with visual indicators - Add InterruptionHandler component for managing interruptions - Add Pill UI component for status display - Add queueStorage utility for persistence - Integrate queue system into ChatInput with keyboard shortcuts - Support for editing queued messages and priority sending Addresses #4179 --- ui/desktop/src/components/ChatInput.tsx | 332 +++++++++++++- .../src/components/InterruptionHandler.tsx | 224 +++++++++ ui/desktop/src/components/MessageQueue.tsx | 426 ++++++++++++++++++ ui/desktop/src/components/ui/Pill.tsx | 109 +++++ ui/desktop/src/utils/interruptionDetector.ts | 132 ++++++ ui/desktop/src/utils/queueStorage.ts | 97 ++++ 6 files changed, 1317 insertions(+), 3 deletions(-) create mode 100644 ui/desktop/src/components/InterruptionHandler.tsx create mode 100644 ui/desktop/src/components/MessageQueue.tsx create mode 100644 ui/desktop/src/components/ui/Pill.tsx create mode 100644 ui/desktop/src/utils/interruptionDetector.ts create mode 100644 ui/desktop/src/utils/queueStorage.ts diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 76e1b0858c57..92e1058e743e 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -27,6 +27,15 @@ import { COST_TRACKING_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; import { DroppedFile, useFileDrop } from '../hooks/useFileDrop'; import { Recipe } from '../recipe'; +import { QueueStorage } from '../utils/queueStorage'; +import MessageQueue from './MessageQueue'; +import { detectInterruption, isInterruptionCommand } from '../utils/interruptionDetector'; + +interface QueuedMessage { + id: string; + content: string; + timestamp: number; +} interface PastedImage { id: string; @@ -104,8 +113,14 @@ export default function ChatInput({ const [isFocused, setIsFocused] = useState(false); const [pastedImages, setPastedImages] = useState([]); - // Derived state - chatState != Idle means we're in some form of loading state + // Queue functionality + const [queuedMessages, setQueuedMessages] = useState([]); const isLoading = chatState !== ChatState.Idle; + const wasLoadingRef = useRef(isLoading); + const queuePausedRef = useRef(false); + const editingMessageIdRef = useRef(null); + const [lastInterruption, setLastInterruption] = useState(null); + const { alerts, addAlert, clearAlerts } = useAlerts(); const dropdownRef = useRef(null); const toolCount = useToolCount(); @@ -124,6 +139,17 @@ export default function ChatInput({ useEffect(() => { // Debug logging removed - draft functionality is working correctly }, [chatContext?.contextKey, chatContext?.draft, chatContext]); + + // Queue processing + useEffect(() => { + if (wasLoadingRef.current && !isLoading && queuedMessages.length > 0 && !queuePausedRef.current) { + const nextMessage = queuedMessages[0]; + LocalMessageStorage.addMessage(nextMessage.content); + handleSubmit(new CustomEvent("submit", { detail: { value: nextMessage.content } }) as unknown as React.FormEvent); + setQueuedMessages(prev => prev.slice(1)); + } + wasLoadingRef.current = isLoading; + }, [isLoading, queuedMessages, handleSubmit]); const [mentionPopover, setMentionPopover] = useState<{ isOpen: boolean; position: { x: number; y: number }; @@ -472,7 +498,57 @@ export default function ChatInput({ // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { - return () => { + + // Queue management functions + 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; + + if (onStop) onStop(); + queuePausedRef.current = true; + setLastInterruption("manual"); + + 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); + }; + + 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 => prev.slice(1)); + } + }; + + return () => { // Clear any pending timeouts from image processing setPastedImages((currentImages) => { currentImages.forEach((img) => { @@ -693,7 +769,57 @@ export default function ChatInput({ // Cleanup debounced functions on unmount useEffect(() => { - return () => { + + // Queue management functions + 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; + + if (onStop) onStop(); + queuePausedRef.current = true; + setLastInterruption("manual"); + + 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); + }; + + 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 => prev.slice(1)); + } + }; + + return () => { debouncedAutosize.cancel?.(); debouncedSaveDraft.cancel?.(); }; @@ -844,6 +970,33 @@ export default function ChatInput({ if (mentionPopover.isOpen && mentionPopoverRef.current) { if (evt.key === 'ArrowDown') { evt.preventDefault(); + + // Handle interruption and queue logic + if (isLoading && displayValue.trim()) { + const interruptionMatch = detectInterruption(displayValue.trim()); + + if (interruptionMatch && interruptionMatch.shouldInterrupt) { + setLastInterruption(interruptionMatch.matchedText); + if (onStop) onStop(); + queuePausedRef.current = true; + + LocalMessageStorage.addMessage(displayValue.trim()); + handleSubmit(new CustomEvent("submit", { detail: { value: displayValue.trim() } }) as unknown as React.FormEvent); + setDisplayValue(""); + setValue(""); + return; + } + + const newMessage = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + content: displayValue.trim(), + timestamp: Date.now() + }; + setQueuedMessages(prev => [...prev, newMessage]); + setDisplayValue(""); + setValue(""); + return; + } const displayFiles = mentionPopoverRef.current.getDisplayFiles(); const maxIndex = Math.max(0, displayFiles.length - 1); setMentionPopover((prev) => ({ @@ -854,6 +1007,33 @@ export default function ChatInput({ } if (evt.key === 'ArrowUp') { evt.preventDefault(); + + // Handle interruption and queue logic + if (isLoading && displayValue.trim()) { + const interruptionMatch = detectInterruption(displayValue.trim()); + + if (interruptionMatch && interruptionMatch.shouldInterrupt) { + setLastInterruption(interruptionMatch.matchedText); + if (onStop) onStop(); + queuePausedRef.current = true; + + LocalMessageStorage.addMessage(displayValue.trim()); + handleSubmit(new CustomEvent("submit", { detail: { value: displayValue.trim() } }) as unknown as React.FormEvent); + setDisplayValue(""); + setValue(""); + return; + } + + const newMessage = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + content: displayValue.trim(), + timestamp: Date.now() + }; + setQueuedMessages(prev => [...prev, newMessage]); + setDisplayValue(""); + setValue(""); + return; + } setMentionPopover((prev) => ({ ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0), @@ -862,11 +1042,65 @@ export default function ChatInput({ } if (evt.key === 'Enter') { evt.preventDefault(); + + // Handle interruption and queue logic + if (isLoading && displayValue.trim()) { + const interruptionMatch = detectInterruption(displayValue.trim()); + + if (interruptionMatch && interruptionMatch.shouldInterrupt) { + setLastInterruption(interruptionMatch.matchedText); + if (onStop) onStop(); + queuePausedRef.current = true; + + LocalMessageStorage.addMessage(displayValue.trim()); + handleSubmit(new CustomEvent("submit", { detail: { value: displayValue.trim() } }) as unknown as React.FormEvent); + setDisplayValue(""); + setValue(""); + return; + } + + const newMessage = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + content: displayValue.trim(), + timestamp: Date.now() + }; + setQueuedMessages(prev => [...prev, newMessage]); + setDisplayValue(""); + setValue(""); + return; + } mentionPopoverRef.current.selectFile(mentionPopover.selectedIndex); return; } if (evt.key === 'Escape') { evt.preventDefault(); + + // Handle interruption and queue logic + if (isLoading && displayValue.trim()) { + const interruptionMatch = detectInterruption(displayValue.trim()); + + if (interruptionMatch && interruptionMatch.shouldInterrupt) { + setLastInterruption(interruptionMatch.matchedText); + if (onStop) onStop(); + queuePausedRef.current = true; + + LocalMessageStorage.addMessage(displayValue.trim()); + handleSubmit(new CustomEvent("submit", { detail: { value: displayValue.trim() } }) as unknown as React.FormEvent); + setDisplayValue(""); + setValue(""); + return; + } + + const newMessage = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + content: displayValue.trim(), + timestamp: Date.now() + }; + setQueuedMessages(prev => [...prev, newMessage]); + setDisplayValue(""); + setValue(""); + return; + } setMentionPopover((prev) => ({ ...prev, isOpen: false })); return; } @@ -890,6 +1124,33 @@ export default function ChatInput({ } evt.preventDefault(); + + // Handle interruption and queue logic + if (isLoading && displayValue.trim()) { + const interruptionMatch = detectInterruption(displayValue.trim()); + + if (interruptionMatch && interruptionMatch.shouldInterrupt) { + setLastInterruption(interruptionMatch.matchedText); + if (onStop) onStop(); + queuePausedRef.current = true; + + LocalMessageStorage.addMessage(displayValue.trim()); + handleSubmit(new CustomEvent("submit", { detail: { value: displayValue.trim() } }) as unknown as React.FormEvent); + setDisplayValue(""); + setValue(""); + return; + } + + const newMessage = { + id: Date.now().toString() + Math.random().toString(36).substr(2, 9), + content: displayValue.trim(), + timestamp: Date.now() + }; + setQueuedMessages(prev => [...prev, newMessage]); + setDisplayValue(""); + setValue(""); + return; + } const canSubmit = !isLoading && !isLoadingCompaction && @@ -954,6 +1215,56 @@ export default function ChatInput({ const isAnyImageLoading = pastedImages.some((img) => img.isLoading); const isAnyDroppedFileLoading = allDroppedFiles.some((file) => file.isLoading); + + // Queue management functions + 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; + + if (onStop) onStop(); + queuePausedRef.current = true; + setLastInterruption("manual"); + + 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); + }; + + 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 => prev.slice(1)); + } + }; + 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..52023e6a2fa5 --- /dev/null +++ b/ui/desktop/src/components/InterruptionHandler.tsx @@ -0,0 +1,224 @@ +import React, { useState, useEffect } from 'react'; +import { AlertTriangle, StopCircle, PauseCircle, RotateCcw, Zap, AlertCircle } from 'lucide-react'; +import { Button } from './ui/button'; +import { InterruptionMatch, getInterruptionMessage } 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 message = getInterruptionMessage(match); + + 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 && ( +
+ +