Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
31 changes: 10 additions & 21 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}, [selectedType, searchQuery])

const handleEnhancePrompt = useCallback(() => {
if (sendingDisabled) {
return
}

const trimmedInput = inputValue.trim()

if (trimmedInput) {
Expand All @@ -215,7 +211,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
} else {
setInputValue(t("chat:enhancePromptDescription"))
}
}, [inputValue, sendingDisabled, setInputValue, t])
}, [inputValue, setInputValue, t])

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

Expand Down Expand Up @@ -435,11 +431,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) {
Expand Down Expand Up @@ -487,7 +481,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
},
[
sendingDisabled,
onSend,
showContextMenu,
searchQuery,
Expand Down Expand Up @@ -1145,8 +1138,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",
Expand All @@ -1156,9 +1149,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>
Expand All @@ -1170,8 +1161,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",
Expand All @@ -1181,9 +1172,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>
Expand Down
184 changes: 151 additions & 33 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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={() => {
Expand Down Expand Up @@ -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}
Expand Down
Loading