diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 0c87655fc0c..eaec2ad8865 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -174,3 +174,19 @@ export const tokenUsageSchema = z.object({ }) export type TokenUsage = z.infer + +/** + * QueuedMessage + */ + +/** + * Represents a message that is queued to be sent when sending is enabled + */ +export interface QueuedMessage { + /** Unique identifier for the queued message */ + id: string + /** The text content of the message */ + text: string + /** Array of image data URLs attached to the message */ + images: string[] +} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e179013203a..c4690923847 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -203,10 +203,6 @@ const ChatTextArea = forwardRef( }, [selectedType, searchQuery]) const handleEnhancePrompt = useCallback(() => { - if (sendingDisabled) { - return - } - const trimmedInput = inputValue.trim() if (trimmedInput) { @@ -215,7 +211,7 @@ const ChatTextArea = forwardRef( } else { setInputValue(t("chat:enhancePromptDescription")) } - }, [inputValue, sendingDisabled, setInputValue, t]) + }, [inputValue, setInputValue, t]) const allModes = useMemo(() => getAllModes(customModes), [customModes]) @@ -435,11 +431,9 @@ const ChatTextArea = forwardRef( if (event.key === "Enter" && !event.shiftKey && !isComposing) { event.preventDefault() - if (!sendingDisabled) { - // Reset history navigation state when sending - resetHistoryNavigation() - onSend() - } + // Always call onSend - let ChatView handle queueing when disabled + resetHistoryNavigation() + onSend() } if (event.key === "Backspace" && !isComposing) { @@ -487,7 +481,6 @@ const ChatTextArea = forwardRef( } }, [ - sendingDisabled, onSend, showContextMenu, searchQuery, @@ -1145,8 +1138,8 @@ const ChatTextArea = forwardRef( @@ -1170,8 +1161,8 @@ const ChatTextArea = forwardRef( diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index efd2db856c0..3c4ffd3208a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -54,7 +54,9 @@ import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" +import QueuedMessages from "./QueuedMessages" import { getLatestTodo } from "@roo/todo" +import { QueuedMessage } from "@roo-code/types" export interface ChatViewProps { isHidden: boolean @@ -154,6 +156,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [messageQueue, setMessageQueue] = useState([]) + const isProcessingQueueRef = useRef(false) + const retryCountRef = useRef>(new Map()) + const MAX_RETRY_ATTEMPTS = 3 // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [clineAsk, setClineAsk] = useState(undefined) @@ -439,6 +445,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -538,47 +549,133 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - text = text.trim() - - if (text || images.length > 0) { - // Mark that user has responded - this prevents any pending auto-approvals - userRespondedRef.current = true - - if (messagesRef.current.length === 0) { - vscode.postMessage({ type: "newTask", text, images }) - } else if (clineAskRef.current) { - if (clineAskRef.current === "followup") { - markFollowUpAsAnswered() + (text: string, images: string[], fromQueue = false) => { + try { + text = text.trim() + + if (text || images.length > 0) { + if (sendingDisabled && !fromQueue) { + // Generate a more unique ID using timestamp + random component + const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + setMessageQueue((prev) => [...prev, { id: messageId, text, images }]) + setInputValue("") + setSelectedImages([]) + return } + // Mark that user has responded - this prevents any pending auto-approvals + userRespondedRef.current = true + + if (messagesRef.current.length === 0) { + vscode.postMessage({ type: "newTask", text, images }) + } else if (clineAskRef.current) { + if (clineAskRef.current === "followup") { + markFollowUpAsAnswered() + } - // Use clineAskRef.current - switch ( - clineAskRef.current // Use clineAskRef.current - ) { - case "followup": - case "tool": - case "browser_action_launch": - case "command": // User can provide feedback to a tool or command use. - case "command_output": // User can send input to command stdin. - case "use_mcp_server": - case "completion_result": // If this happens then the user has feedback for the completion result. - case "resume_task": - case "resume_completed_task": - case "mistake_limit_reached": - vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) - break - // There is no other case that a textfield should be enabled. + // Use clineAskRef.current + switch ( + clineAskRef.current // Use clineAskRef.current + ) { + case "followup": + case "tool": + case "browser_action_launch": + case "command": // User can provide feedback to a tool or command use. + case "command_output": // User can send input to command stdin. + case "use_mcp_server": + case "completion_result": // If this happens then the user has feedback for the completion result. + case "resume_task": + case "resume_completed_task": + case "mistake_limit_reached": + vscode.postMessage({ + type: "askResponse", + askResponse: "messageResponse", + text, + images, + }) + break + // There is no other case that a textfield should be enabled. + } + } else { + // This is a new message in an ongoing task. + vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images }) } - } - handleChatReset() + handleChatReset() + } + } catch (error) { + console.error("Error in handleSendMessage:", error) + // If this was a queued message, we should handle it differently + if (fromQueue) { + throw error // Re-throw to be caught by the queue processor + } + // For direct sends, we could show an error to the user + // but for now we'll just log it } }, - [handleChatReset, markFollowUpAsAnswered], // messagesRef and clineAskRef are stable + [handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable ) + useEffect(() => { + // Early return if conditions aren't met + // Also don't process queue if there's an API error (clineAsk === "api_req_failed") + if ( + sendingDisabled || + messageQueue.length === 0 || + isProcessingQueueRef.current || + clineAsk === "api_req_failed" + ) { + return + } + + // Mark as processing immediately to prevent race conditions + isProcessingQueueRef.current = true + + // Process the first message in the queue + const [nextMessage, ...remaining] = messageQueue + + // Update queue immediately to prevent duplicate processing + setMessageQueue(remaining) + + // Process the message + Promise.resolve() + .then(() => { + handleSendMessage(nextMessage.text, nextMessage.images, true) + // Clear retry count on success + retryCountRef.current.delete(nextMessage.id) + }) + .catch((error) => { + console.error("Failed to send queued message:", error) + + // Get current retry count + const retryCount = retryCountRef.current.get(nextMessage.id) || 0 + + // Only re-add if under retry limit + if (retryCount < MAX_RETRY_ATTEMPTS) { + retryCountRef.current.set(nextMessage.id, retryCount + 1) + // Re-add the message to the end of the queue + setMessageQueue((current) => [...current, nextMessage]) + } else { + console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`) + retryCountRef.current.delete(nextMessage.id) + } + }) + .finally(() => { + isProcessingQueueRef.current = false + }) + + // Cleanup function to handle component unmount + return () => { + isProcessingQueueRef.current = false + } + }, [sendingDisabled, messageQueue, handleSendMessage, clineAsk]) + const handleSetChatBoxMessage = useCallback( (text: string, images: string[]) => { // Avoid nested template literals by breaking down the logic @@ -594,6 +691,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Store refs in variables to avoid stale closure issues + const retryCountMap = retryCountRef.current + const isProcessingRef = isProcessingQueueRef + + return () => { + retryCountMap.clear() + isProcessingRef.current = false + } + }, []) + const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), []) // This logic depends on the useEffect[messages] above to set clineAsk, @@ -1630,7 +1739,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction +
{(showAnnouncement || showAnnouncementModal) && ( { @@ -1836,6 +1947,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction )} + setMessageQueue((prev) => prev.filter((_, i) => i !== index))} + onUpdate={(index, newText) => { + setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg))) + }} + /> void + onUpdate: (index: number, newText: string) => void +} + +const QueuedMessages: React.FC = ({ queue, onRemove, onUpdate }) => { + const { t } = useTranslation("chat") + const [editingStates, setEditingStates] = useState>({}) + + if (queue.length === 0) { + return null + } + + const getEditState = (messageId: string, currentText: string) => { + return editingStates[messageId] || { isEditing: false, value: currentText } + } + + const setEditState = (messageId: string, isEditing: boolean, value?: string) => { + setEditingStates((prev) => ({ + ...prev, + [messageId]: { isEditing, value: value ?? prev[messageId]?.value ?? "" }, + })) + } + + const handleSaveEdit = (index: number, messageId: string, newValue: string) => { + onUpdate(index, newValue) + setEditState(messageId, false) + } + + return ( +
+
{t("queuedMessages.title")}
+
+ {queue.map((message, index) => { + const editState = getEditState(message.id, message.text) + + return ( +
+
+
+ {editState.isEditing ? ( +