feat: Add support for message queueing (#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 <roomote@roocode.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2025-07-27 14:32:53 -04:00 committed by GitHub
parent b73c17a375
commit f546e6ed1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 811 additions and 518 deletions

View file

@ -174,3 +174,19 @@ export const tokenUsageSchema = z.object({
})
export type TokenUsage = z.infer<typeof tokenUsageSchema>
/**
* 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[]
}

View file

@ -204,10 +204,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [selectedType, searchQuery])
const handleEnhancePrompt = useCallback(() => {
if (sendingDisabled) {
return
}
const trimmedInput = inputValue.trim()
if (trimmedInput) {
@ -216,7 +212,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} else {
setInputValue(t("chat:enhancePromptDescription"))
}
}, [inputValue, sendingDisabled, setInputValue, t])
}, [inputValue, setInputValue, t])
const allModes = useMemo(() => getAllModes(customModes), [customModes])
@ -436,11 +432,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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) {
@ -488,7 +482,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
sendingDisabled,
onSend,
showContextMenu,
searchQuery,
@ -1034,8 +1027,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? handleEnhancePrompt : undefined}
disabled={false}
onClick={handleEnhancePrompt}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
@ -1045,9 +1038,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"cursor-pointer",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
@ -1059,8 +1050,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
disabled={false}
onClick={onSend}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
@ -1070,9 +1061,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
</button>

View file

@ -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<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [messageQueue, setMessageQueue] = useState<QueuedMessage[]>([])
const isProcessingQueueRef = useRef(false)
const retryCountRef = useRef<Map<string, number>>(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<ClineAsk | undefined>(undefined)
@ -439,6 +445,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
// Reset user response flag for new task
userRespondedRef.current = false
// Clear message queue when starting a new task
setMessageQueue([])
// Clear retry counts
retryCountRef.current.clear()
}, [task?.ts])
useEffect(() => {
@ -538,47 +549,133 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
disableAutoScrollRef.current = false
}, [])
/**
* Handles sending messages to the extension
* @param text - The message text to send
* @param images - Array of image data URLs to send with the message
* @param fromQueue - Internal flag indicating if this message is being sent from the queue (prevents re-queueing)
*/
const handleSendMessage = useCallback(
(text: string, images: string[]) => {
text = text.trim()
(text: string, images: string[], fromQueue = false) => {
try {
text = text.trim()
if (text || images.length > 0) {
// Mark that user has responded - this prevents any pending auto-approvals
userRespondedRef.current = true
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()
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.
}
} else {
// This is a new message in an ongoing task.
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
}
// 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.
}
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<ChatViewRef, ChatViewPro
[inputValue, selectedImages],
)
// Cleanup retry count map on unmount
useEffect(() => {
// 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<ChatViewRef, ChatViewPro
const areButtonsVisible = showScrollToBottom || primaryButtonText || secondaryButtonText || isStreaming
return (
<div className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
<div
data-testid="chat-view"
className={isHidden ? "hidden" : "fixed top-0 left-0 right-0 bottom-0 flex flex-col overflow-hidden"}>
{(showAnnouncement || showAnnouncementModal) && (
<Announcement
hideAnnouncement={() => {
@ -1836,6 +1947,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
</>
)}
<QueuedMessages
queue={messageQueue}
onRemove={(index) => setMessageQueue((prev) => prev.filter((_, i) => i !== index))}
onUpdate={(index, newText) => {
setMessageQueue((prev) => prev.map((msg, i) => (i === index ? { ...msg, text: newText } : msg)))
}}
/>
<ChatTextArea
ref={textAreaRef}
inputValue={inputValue}

View file

@ -0,0 +1,112 @@
import React, { useState } from "react"
import { useTranslation } from "react-i18next"
import Thumbnails from "../common/Thumbnails"
import { QueuedMessage } from "@roo-code/types"
import { Mention } from "./Mention"
import { Button } from "@src/components/ui"
interface QueuedMessagesProps {
queue: QueuedMessage[]
onRemove: (index: number) => void
onUpdate: (index: number, newText: string) => void
}
const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpdate }) => {
const { t } = useTranslation("chat")
const [editingStates, setEditingStates] = useState<Record<string, { isEditing: boolean; value: string }>>({})
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 (
<div className="px-[15px] py-[10px] pr-[6px]" data-testid="queued-messages">
<div className="text-vscode-descriptionForeground text-md mb-2">{t("queuedMessages.title")}</div>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{queue.map((message, index) => {
const editState = getEditState(message.id, message.text)
return (
<div
key={message.id}
className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap flex-shrink-0">
<div className="flex justify-between">
<div className="flex-grow px-2 py-1 wrap-anywhere">
{editState.isEditing ? (
<textarea
ref={(textarea) => {
if (textarea) {
// Set cursor at the end
textarea.setSelectionRange(
textarea.value.length,
textarea.value.length,
)
}
}}
value={editState.value}
onChange={(e) => setEditState(message.id, true, e.target.value)}
onBlur={() => handleSaveEdit(index, message.id, editState.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSaveEdit(index, message.id, editState.value)
}
if (e.key === "Escape") {
setEditState(message.id, false, message.text)
}
}}
className="w-full bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1 resize-none focus:outline-0 focus:ring-1 focus:ring-vscode-focusBorder"
placeholder={t("chat:editMessage.placeholder")}
autoFocus
rows={Math.min(editState.value.split("\n").length, 10)}
/>
) : (
<div
onClick={() => setEditState(message.id, true, message.text)}
className="cursor-pointer hover:bg-vscode-list-hoverBackground px-1 py-0.5 -mx-1 -my-0.5 rounded transition-colors"
title={t("chat:queuedMessages.clickToEdit")}>
<Mention text={message.text} withShadow />
</div>
)}
</div>
<div className="flex">
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={(e) => {
e.stopPropagation()
onRemove(index)
}}>
<span className="codicon codicon-trash" />
</Button>
</div>
</div>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
)
})}
</div>
</div>
)
}
export default QueuedMessages

View file

@ -77,7 +77,7 @@ describe("ChatTextArea", () => {
})
describe("enhance prompt button", () => {
it("should be disabled when sendingDisabled is true", () => {
it("should be enabled even when sendingDisabled is true (for message queueing)", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
@ -86,7 +86,7 @@ describe("ChatTextArea", () => {
})
render(<ChatTextArea {...defaultProps} sendingDisabled={true} />)
const enhanceButton = getEnhancePromptButton()
expect(enhanceButton).toHaveClass("cursor-not-allowed")
expect(enhanceButton).toHaveClass("cursor-pointer")
})
})

File diff suppressed because it is too large Load diff

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Activa la comanda {{name}}"
},
"queuedMessages": {
"title": "Missatges en cua:",
"clickToEdit": "Feu clic per editar el missatge"
}
}

View file

@ -351,5 +351,9 @@
},
"editMessage": {
"placeholder": "Bearbeite deine Nachricht..."
},
"queuedMessages": {
"title": "Warteschlange Nachrichten:",
"clickToEdit": "Klicken zum Bearbeiten der Nachricht"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Trigger the {{name}} command"
},
"queuedMessages": {
"title": "Queued Messages:",
"clickToEdit": "Click to edit message"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Activar el comando {{name}}"
},
"queuedMessages": {
"title": "Mensajes en cola:",
"clickToEdit": "Haz clic para editar el mensaje"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Déclencher la commande {{name}}"
},
"queuedMessages": {
"title": "Messages en file d'attente :",
"clickToEdit": "Cliquez pour modifier le message"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "{{name}} कमांड को ट्रिगर करें"
},
"queuedMessages": {
"title": "कतार में संदेश:",
"clickToEdit": "संदेश संपादित करने के लिए क्लिक करें"
}
}

View file

@ -357,5 +357,9 @@
},
"command": {
"triggerDescription": "Jalankan perintah {{name}}"
},
"queuedMessages": {
"title": "Pesan Antrian:",
"clickToEdit": "Klik untuk mengedit pesan"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Attiva il comando {{name}}"
},
"queuedMessages": {
"title": "Messaggi in coda:",
"clickToEdit": "Clicca per modificare il messaggio"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "{{name}}コマンドをトリガー"
},
"queuedMessages": {
"title": "キューメッセージ:",
"clickToEdit": "クリックしてメッセージを編集"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "{{name}} 명령 트리거"
},
"queuedMessages": {
"title": "대기열 메시지:",
"clickToEdit": "클릭하여 메시지 편집"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Activeer de {{name}} opdracht"
},
"queuedMessages": {
"title": "Berichten in wachtrij:",
"clickToEdit": "Klik om bericht te bewerken"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Uruchom polecenie {{name}}"
},
"queuedMessages": {
"title": "Wiadomości w kolejce:",
"clickToEdit": "Kliknij, aby edytować wiadomość"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Acionar o comando {{name}}"
},
"queuedMessages": {
"title": "Mensagens na fila:",
"clickToEdit": "Clique para editar a mensagem"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Запустить команду {{name}}"
},
"queuedMessages": {
"title": "Сообщения в очереди:",
"clickToEdit": "Нажмите, чтобы редактировать сообщение"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "{{name}} komutunu tetikle"
},
"queuedMessages": {
"title": "Sıradaki Mesajlar:",
"clickToEdit": "Mesajı düzenlemek için tıkla"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "Kích hoạt lệnh {{name}}"
},
"queuedMessages": {
"title": "Tin nhắn trong hàng đợi:",
"clickToEdit": "Nhấp để chỉnh sửa tin nhắn"
}
}

View file

@ -351,5 +351,9 @@
},
"editMessage": {
"placeholder": "编辑消息..."
},
"queuedMessages": {
"title": "队列消息:",
"clickToEdit": "点击编辑消息"
}
}

View file

@ -351,5 +351,9 @@
},
"command": {
"triggerDescription": "觸發 {{name}} 命令"
},
"queuedMessages": {
"title": "佇列訊息:",
"clickToEdit": "點擊編輯訊息"
}
}