|
|
@@ -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()
|
|
|
-
|
|
|
- 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<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}
|