Skip to content

Commit 7763c68

Browse files
roomote[bot]roomotedaniel-lxsmrubens
authored andcommitted
feat: Add support for message queueing (RooCodeInc#6167)
* feat: Add support for message queueing * fix: address PR review feedback for message queueing feature - Restore original ChatView tests from main branch - Fix broken test by updating ChatTextArea mock - Add comprehensive tests for message queueing (simplified due to mocking constraints) - Fix race condition using useRef and setTimeout in queue processing - Extract QueuedMessage interface to shared types.ts file - Replace inline styles with Tailwind classes in QueuedMessages - Add i18n support for 'Queued Messages:' text - Add keyboard navigation for removing queued messages - Add JSDoc for fromQueue parameter in handleSendMessage * refactor: move QueuedMessage interface to packages/types - Move QueuedMessage interface from local types.ts to packages/types/src/message.ts - Update imports in ChatView.tsx and QueuedMessages.tsx to use @roo-code/types - Remove local types.ts file to follow codebase conventions * fix: add id field when creating queued messages - Generate unique id using timestamp when adding messages to queue - Fixes TypeScript error after moving QueuedMessage interface * Stop disabling sending * Translations * Fix tests * Improved styling * Remove unused string * Test cleanup * fix: address message queueing issues - Fix race condition in queue processing by re-checking queue state inside setTimeout - Add error handling for queue operations with retry mechanism - Replace array index with stable message.id for React keys in QueuedMessages - Generate more unique IDs using timestamp + random component * feat: add inline editing for queued messages - Add ability to edit queued messages by clicking on them - Support Enter to save and Escape to cancel edits - Add textarea that auto-resizes based on content - Add hover effect to indicate messages are editable - Add translation for click to edit tooltip * feat: add scrollbar and fix height for queued messages - Add max-height of 300px with scrollbar to queue container - Add flex-shrink-0 to prevent message items from being squished - Ensure consistent height for message items when multiple messages are queued * feat: add translations for queued message edit tooltip - Add 'queuedMessages.clickToEdit' translation key to all 18 language files - Provides localized tooltip text for the click-to-edit functionality * fix: improve message queue processing reliability - Fix race condition by removing nested setState and setTimeout - Add retry limit (3 attempts) to prevent infinite loops - Add proper cleanup on component unmount - Clear queue when starting new task - Prevent queue processing during API errors - Fix ESLint warnings for React hooks dependencies --------- Co-authored-by: Roo Code <[email protected]> Co-authored-by: Daniel Riccio <[email protected]> Co-authored-by: Matt Rubens <[email protected]>
1 parent 1c3d226 commit 7763c68

File tree

25 files changed

+847
-554
lines changed

25 files changed

+847
-554
lines changed

packages/types/src/message.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,19 @@ export const tokenUsageSchema = z.object({
174174
})
175175

176176
export type TokenUsage = z.infer<typeof tokenUsageSchema>
177+
178+
/**
179+
* QueuedMessage
180+
*/
181+
182+
/**
183+
* Represents a message that is queued to be sent when sending is enabled
184+
*/
185+
export interface QueuedMessage {
186+
/** Unique identifier for the queued message */
187+
id: string
188+
/** The text content of the message */
189+
text: string
190+
/** Array of image data URLs attached to the message */
191+
images: string[]
192+
}

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,4 @@
511511
"vitest": "^3.2.3",
512512
"zod-to-ts": "^1.2.0"
513513
}
514-
}
514+
}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
204204
}, [selectedType, searchQuery])
205205

206206
const handleEnhancePrompt = useCallback(() => {
207-
if (sendingDisabled) {
208-
return
209-
}
210-
211207
const trimmedInput = inputValue.trim()
212208

213209
if (trimmedInput) {
@@ -216,7 +212,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
216212
} else {
217213
setInputValue(t("chat:enhancePromptDescription"))
218214
}
219-
}, [inputValue, sendingDisabled, setInputValue, t])
215+
}, [inputValue, setInputValue, t])
220216

221217
const allModes = useMemo(() => getAllModes(customModes), [customModes])
222218

@@ -436,11 +432,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
436432
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
437433
event.preventDefault()
438434

439-
if (!sendingDisabled) {
440-
// Reset history navigation state when sending
441-
resetHistoryNavigation()
442-
onSend()
443-
}
435+
// Always call onSend - let ChatView handle queueing when disabled
436+
resetHistoryNavigation()
437+
onSend()
444438
}
445439

446440
if (event.key === "Backspace" && !isComposing) {
@@ -488,7 +482,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
488482
}
489483
},
490484
[
491-
sendingDisabled,
492485
onSend,
493486
showContextMenu,
494487
searchQuery,
@@ -1034,8 +1027,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10341027
<StandardTooltip content={t("chat:enhancePrompt")}>
10351028
<button
10361029
aria-label={t("chat:enhancePrompt")}
1037-
disabled={sendingDisabled}
1038-
onClick={!sendingDisabled ? handleEnhancePrompt : undefined}
1030+
disabled={false}
1031+
onClick={handleEnhancePrompt}
10391032
className={cn(
10401033
"relative inline-flex items-center justify-center",
10411034
"bg-transparent border-none p-1.5",
@@ -1045,9 +1038,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10451038
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
10461039
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
10471040
"active:bg-[rgba(255,255,255,0.1)]",
1048-
!sendingDisabled && "cursor-pointer",
1049-
sendingDisabled &&
1050-
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
1041+
"cursor-pointer",
10511042
)}>
10521043
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
10531044
</button>
@@ -1059,8 +1050,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10591050
<StandardTooltip content={t("chat:sendMessage")}>
10601051
<button
10611052
aria-label={t("chat:sendMessage")}
1062-
disabled={sendingDisabled}
1063-
onClick={!sendingDisabled ? onSend : undefined}
1053+
disabled={false}
1054+
onClick={onSend}
10641055
className={cn(
10651056
"relative inline-flex items-center justify-center",
10661057
"bg-transparent border-none p-1.5",
@@ -1070,9 +1061,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10701061
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
10711062
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
10721063
"active:bg-[rgba(255,255,255,0.1)]",
1073-
!sendingDisabled && "cursor-pointer",
1074-
sendingDisabled &&
1075-
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
1064+
"cursor-pointer",
10761065
)}>
10771066
<SendHorizontal className="w-4 h-4" />
10781067
</button>

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 151 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ import AutoApproveMenu from "./AutoApproveMenu"
5454
import SystemPromptWarning from "./SystemPromptWarning"
5555
import ProfileViolationWarning from "./ProfileViolationWarning"
5656
import { CheckpointWarning } from "./CheckpointWarning"
57+
import QueuedMessages from "./QueuedMessages"
5758
import { getLatestTodo } from "@roo/todo"
59+
import { QueuedMessage } from "@roo-code/types"
5860

5961
export interface ChatViewProps {
6062
isHidden: boolean
@@ -154,6 +156,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
154156
const textAreaRef = useRef<HTMLTextAreaElement>(null)
155157
const [sendingDisabled, setSendingDisabled] = useState(false)
156158
const [selectedImages, setSelectedImages] = useState<string[]>([])
159+
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
160+
const isProcessingQueueRef = useRef(false)
161+
const retryCountRef = useRef<Map<string, number>>(new Map())
162+
const MAX_RETRY_ATTEMPTS = 3
157163

158164
// 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)
159165
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
@@ -439,6 +445,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
439445
}
440446
// Reset user response flag for new task
441447
userRespondedRef.current = false
448+
449+
// Clear message queue when starting a new task
450+
setMessageQueue([])
451+
// Clear retry counts
452+
retryCountRef.current.clear()
442453
}, [task?.ts])
443454

444455
useEffect(() => {
@@ -538,47 +549,133 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
538549
disableAutoScrollRef.current = false
539550
}, [])
540551

552+
/**
553+
* Handles sending messages to the extension
554+
* @param text - The message text to send
555+
* @param images - Array of image data URLs to send with the message
556+
* @param fromQueue - Internal flag indicating if this message is being sent from the queue (prevents re-queueing)
557+
*/
541558
const handleSendMessage = useCallback(
542-
(text: string, images: string[]) => {
543-
text = text.trim()
544-
545-
if (text || images.length > 0) {
546-
// Mark that user has responded - this prevents any pending auto-approvals
547-
userRespondedRef.current = true
548-
549-
if (messagesRef.current.length === 0) {
550-
vscode.postMessage({ type: "newTask", text, images })
551-
} else if (clineAskRef.current) {
552-
if (clineAskRef.current === "followup") {
553-
markFollowUpAsAnswered()
559+
(text: string, images: string[], fromQueue = false) => {
560+
try {
561+
text = text.trim()
562+
563+
if (text || images.length > 0) {
564+
if (sendingDisabled && !fromQueue) {
565+
// Generate a more unique ID using timestamp + random component
566+
const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
567+
setMessageQueue((prev) => [...prev, { id: messageId, text, images }])
568+
setInputValue("")
569+
setSelectedImages([])
570+
return
554571
}
572+
// Mark that user has responded - this prevents any pending auto-approvals
573+
userRespondedRef.current = true
574+
575+
if (messagesRef.current.length === 0) {
576+
vscode.postMessage({ type: "newTask", text, images })
577+
} else if (clineAskRef.current) {
578+
if (clineAskRef.current === "followup") {
579+
markFollowUpAsAnswered()
580+
}
555581

556-
// Use clineAskRef.current
557-
switch (
558-
clineAskRef.current // Use clineAskRef.current
559-
) {
560-
case "followup":
561-
case "tool":
562-
case "browser_action_launch":
563-
case "command": // User can provide feedback to a tool or command use.
564-
case "command_output": // User can send input to command stdin.
565-
case "use_mcp_server":
566-
case "completion_result": // If this happens then the user has feedback for the completion result.
567-
case "resume_task":
568-
case "resume_completed_task":
569-
case "mistake_limit_reached":
570-
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
571-
break
572-
// There is no other case that a textfield should be enabled.
582+
// Use clineAskRef.current
583+
switch (
584+
clineAskRef.current // Use clineAskRef.current
585+
) {
586+
case "followup":
587+
case "tool":
588+
case "browser_action_launch":
589+
case "command": // User can provide feedback to a tool or command use.
590+
case "command_output": // User can send input to command stdin.
591+
case "use_mcp_server":
592+
case "completion_result": // If this happens then the user has feedback for the completion result.
593+
case "resume_task":
594+
case "resume_completed_task":
595+
case "mistake_limit_reached":
596+
vscode.postMessage({
597+
type: "askResponse",
598+
askResponse: "messageResponse",
599+
text,
600+
images,
601+
})
602+
break
603+
// There is no other case that a textfield should be enabled.
604+
}
605+
} else {
606+
// This is a new message in an ongoing task.
607+
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
573608
}
574-
}
575609

576-
handleChatReset()
610+
handleChatReset()
611+
}
612+
} catch (error) {
613+
console.error("Error in handleSendMessage:", error)
614+
// If this was a queued message, we should handle it differently
615+
if (fromQueue) {
616+
throw error // Re-throw to be caught by the queue processor
617+
}
618+
// For direct sends, we could show an error to the user
619+
// but for now we'll just log it
577620
}
578621
},
579-
[handleChatReset, markFollowUpAsAnswered], // messagesRef and clineAskRef are stable
622+
[handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable
580623
)
581624

625+
useEffect(() => {
626+
// Early return if conditions aren't met
627+
// Also don't process queue if there's an API error (clineAsk === "api_req_failed")
628+
if (
629+
sendingDisabled ||
630+
messageQueue.length === 0 ||
631+
isProcessingQueueRef.current ||
632+
clineAsk === "api_req_failed"
633+
) {
634+
return
635+
}
636+
637+
// Mark as processing immediately to prevent race conditions
638+
isProcessingQueueRef.current = true
639+
640+
// Process the first message in the queue
641+
const [nextMessage, ...remaining] = messageQueue
642+
643+
// Update queue immediately to prevent duplicate processing
644+
setMessageQueue(remaining)
645+
646+
// Process the message
647+
Promise.resolve()
648+
.then(() => {
649+
handleSendMessage(nextMessage.text, nextMessage.images, true)
650+
// Clear retry count on success
651+
retryCountRef.current.delete(nextMessage.id)
652+
})
653+
.catch((error) => {
654+
console.error("Failed to send queued message:", error)
655+
656+
// Get current retry count
657+
const retryCount = retryCountRef.current.get(nextMessage.id) || 0
658+
659+
// Only re-add if under retry limit
660+
if (retryCount < MAX_RETRY_ATTEMPTS) {
661+
retryCountRef.current.set(nextMessage.id, retryCount + 1)
662+
// Re-add the message to the end of the queue
663+
setMessageQueue((current) => [...current, nextMessage])
664+
} else {
665+
console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`)
666+
retryCountRef.current.delete(nextMessage.id)
667+
}
668+
})
669+
.finally(() => {
670+
isProcessingQueueRef.current = false
671+
})
672+
673+
// Cleanup function to handle component unmount
674+
return () => {
675+
isProcessingQueueRef.current = false
676+
}
677+
}, [sendingDisabled, messageQueue, handleSendMessage, clineAsk])
678+
582679
const handleSetChatBoxMessage = useCallback(
583680
(text: string, images: string[]) => {
584681
// Avoid nested template literals by breaking down the logic
@@ -594,6 +691,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
594691
[inputValue, selectedImages],
595692
)
596693

694+
// Cleanup retry count map on unmount
695+
useEffect(() => {
696+
// Store refs in variables to avoid stale closure issues
697+
const retryCountMap = retryCountRef.current
698+
const isProcessingRef = isProcessingQueueRef
699+
700+
return () => {
701+
retryCountMap.clear()
702+
isProcessingRef.current = false
703+
}
704+
}, [])
705+
597706
const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), [])
598707

599708
// This logic depends on the useEffect[messages] above to set clineAsk,
@@ -1630,7 +1739,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16301739
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming
16311740

16321741
return (
1633-
<div className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
1742+
<div
1743+
data-testid="chat-view"
1744+
className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
16341745
{(showAnnouncement || showAnnouncementModal) && (
16351746
<Announcement
16361747
hideAnnouncement={() => {
@@ -1836,6 +1947,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
18361947
</>
18371948
)}
18381949

1950+
<QueuedMessages
1951+
queue={messageQueue}
1952+
onRemove={(index) => setMessageQueue((prev) => prev.filter((_, i) => i !== index))}
1953+
onUpdate={(index, newText) => {
1954+
setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg)))
1955+
}}
1956+
/>
18391957
<ChatTextArea
18401958
ref={textAreaRef}
18411959
inputValue={inputValue}

0 commit comments

Comments
 (0)