feat: Add message queueing while AI is processing

- Updated ChatTextArea to show queue icon when sending is disabled
- Modified send button behavior to queue messages instead of being disabled
- Enhanced QueuedMessages component UI with truncated previews
- Added translation keys for queue-related UI text
- Updated tests to cover new queueing behavior

Closes #6287
This commit is contained in:
Roo Code 2025-07-28 08:29:34 +00:00
parent 342ee70fb4
commit 7470c570ff
4 changed files with 298 additions and 48 deletions

View file

@ -26,7 +26,7 @@ import ModeSelector from "./ModeSelector"
import { ApiConfigSelector } from "./ApiConfigSelector"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { VolumeX, Image, WandSparkles, SendHorizontal, ListPlus } from "lucide-react"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { cn } from "@/lib/utils"
import { usePromptHistory } from "./hooks/usePromptHistory"
@ -1047,9 +1047,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{!isEditMode && (
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<StandardTooltip content={sendingDisabled ? t("chat:queueMessage") : t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
aria-label={sendingDisabled ? t("chat:queueMessage") : t("chat:sendMessage")}
disabled={false}
onClick={onSend}
className={cn(
@ -1063,7 +1063,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
{sendingDisabled ? (
<ListPlus className="w-4 h-4" />
) : (
<SendHorizontal className="w-4 h-4" />
)}
</button>
</StandardTooltip>
</div>

View file

@ -1,9 +1,8 @@
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"
import { X, Edit2 } from "lucide-react"
interface QueuedMessagesProps {
queue: QueuedMessage[]
@ -35,19 +34,29 @@ const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpda
setEditState(messageId, false)
}
// Helper function to truncate text with ellipsis
const truncateText = (text: string, maxLength: number = 50) => {
if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + "..."
}
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">
<div className="px-[15px] py-[10px] pr-[6px] border-t border-vscode-panel-border" data-testid="queued-messages">
<div className="flex items-center justify-between mb-2">
<div className="text-vscode-descriptionForeground text-sm font-medium">
{t("chat:queuedMessages.title", { count: queue.length })}
</div>
</div>
<div className="flex flex-col gap-2 max-h-[200px] 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">
className="bg-vscode-list-hoverBackground border border-vscode-panel-border rounded p-2 overflow-hidden flex-shrink-0 transition-all hover:border-vscode-focusBorder">
<div className="flex items-start gap-2">
<div className="flex-grow min-w-0">
{editState.isEditing ? (
<textarea
ref={(textarea) => {
@ -71,36 +80,43 @@ const QueuedMessages: React.FC<QueuedMessagesProps> = ({ queue, onRemove, onUpda
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"
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 text-sm"
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 />
className="cursor-pointer group"
onClick={() => setEditState(message.id, true, message.text)}>
<div className="flex items-center gap-2">
<span
className="text-sm text-vscode-foreground truncate"
title={message.text}>
{truncateText(message.text)}
</span>
<Edit2 className="w-3 h-3 opacity-0 group-hover:opacity-60 transition-opacity" />
</div>
</div>
)}
{message.images && message.images.length > 0 && (
<div className="mt-1 text-xs text-vscode-descriptionForeground">
{t("chat:queuedMessages.withImages", { count: message.images.length })}
</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>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-6 w-6 opacity-60 hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
onRemove(index)
}}
title={t("chat:queuedMessages.remove")}>
<X className="w-3 h-3" />
</Button>
</div>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
)
})}

View file

@ -101,21 +101,23 @@ vi.mock("@src/components/welcome/RooCloudCTA", () => ({
// Mock QueuedMessages component
vi.mock("../QueuedMessages", () => ({
default: function MockQueuedMessages({
messages = [],
onRemoveMessage,
queue = [],
onRemove,
_onUpdate,
}: {
messages?: Array<{ id: string; text: string; images?: string[] }>
onRemoveMessage?: (id: string) => void
queue?: Array<{ id: string; text: string; images?: string[] }>
onRemove?: (index: number) => void
_onUpdate?: (index: number, newText: string) => void
}) {
if (!messages || messages.length === 0) {
if (!queue || queue.length === 0) {
return null
}
return (
<div data-testid="queued-messages">
{messages.map((msg) => (
{queue.map((msg, index) => (
<div key={msg.id}>
<span>{msg.text}</span>
<button aria-label="Remove message" onClick={() => onRemoveMessage?.(msg.id)}>
<button aria-label="Remove message" onClick={() => onRemove?.(index)}>
Remove
</button>
</div>
@ -166,7 +168,7 @@ vi.mock("react-i18next", () => ({
}))
interface ChatTextAreaProps {
onSend: (value: string) => void
onSend: () => void
inputValue?: string
sendingDisabled?: boolean
placeholderText?: string
@ -174,7 +176,6 @@ interface ChatTextAreaProps {
shouldDisableImages?: boolean
}
const mockInputRef = React.createRef<HTMLInputElement>()
const mockFocus = vi.fn()
vi.mock("../ChatTextArea", () => {
@ -194,13 +195,10 @@ vi.mock("../ChatTextArea", () => {
return (
<div data-testid="chat-textarea">
<input
ref={mockInputRef}
type="text"
onChange={(e) => {
// With message queueing, onSend is always called (it handles queueing internally)
props.onSend(e.target.value)
}}
data-sending-disabled={props.sendingDisabled}
value={props.inputValue || ""}
readOnly
/>
</div>
)
@ -1492,4 +1490,233 @@ describe("ChatView - Message Queueing Tests", () => {
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("false")
})
it("queues messages when sending is disabled", async () => {
const { getByTestId, queryByTestId } = renderChatView()
// Hydrate state with active task that should disable sending
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 1000,
text: "Task in progress",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: true, // Partial messages disable sending
},
],
})
// Wait for state to be updated
await waitFor(() => {
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("true")
})
// Clear any previous vscode.postMessage calls
vi.mocked(vscode.postMessage).mockClear()
// Simulate sending a message through the extension message system
// This mimics how the actual ChatTextArea would trigger the send
act(() => {
window.postMessage(
{
type: "invoke",
invoke: "sendMessage",
text: "Test queued message",
images: [],
},
"*",
)
})
// Wait for queued messages to appear
await waitFor(() => {
expect(queryByTestId("queued-messages")).toBeInTheDocument()
})
// Verify the queued message is displayed
const queuedMessages = getByTestId("queued-messages")
expect(queuedMessages.textContent).toContain("Test queued message")
// Verify no message was sent to vscode (it should be queued instead)
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it("allows removing messages from queue", async () => {
const { getByTestId, queryByTestId, getByLabelText } = renderChatView()
// Hydrate state with active task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 1000,
text: "Task in progress",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: true,
},
],
})
// Wait for state to be updated
await waitFor(() => {
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("true")
})
// Queue a message
act(() => {
window.postMessage(
{
type: "invoke",
invoke: "sendMessage",
text: "Message to remove",
images: [],
},
"*",
)
})
// Wait for queued message to appear
await waitFor(() => {
expect(queryByTestId("queued-messages")).toBeInTheDocument()
})
// Click remove button
const removeButton = getByLabelText("Remove message")
act(() => {
removeButton.click()
})
// Verify message is removed
await waitFor(() => {
expect(queryByTestId("queued-messages")).not.toBeInTheDocument()
})
})
it("queues multiple messages in order", async () => {
const { getByTestId } = renderChatView()
// Hydrate state with active task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 1000,
text: "Task in progress",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: true,
},
],
})
// Wait for state to be updated
await waitFor(() => {
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("true")
})
// Queue multiple messages
const messages = ["First message", "Second message", "Third message"]
for (const message of messages) {
act(() => {
window.postMessage(
{
type: "invoke",
invoke: "sendMessage",
text: message,
images: [],
},
"*",
)
})
}
// Wait for all messages to be queued
await waitFor(() => {
const queuedMessages = getByTestId("queued-messages")
expect(queuedMessages).toBeInTheDocument()
// Verify all messages are present in order
const textContent = queuedMessages.textContent!
const firstIndex = textContent.indexOf("First message")
const secondIndex = textContent.indexOf("Second message")
const thirdIndex = textContent.indexOf("Third message")
expect(firstIndex).toBeGreaterThan(-1)
expect(secondIndex).toBeGreaterThan(firstIndex)
expect(thirdIndex).toBeGreaterThan(secondIndex)
})
})
it("does not queue empty messages", async () => {
const { getByTestId, queryByTestId } = renderChatView()
// Hydrate state with active task
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 1000,
text: "Task in progress",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
partial: true,
},
],
})
// Wait for state to be updated
await waitFor(() => {
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")!
expect(input.getAttribute("data-sending-disabled")).toBe("true")
})
// Try to queue an empty message
act(() => {
window.postMessage(
{
type: "invoke",
invoke: "sendMessage",
text: "",
images: [],
},
"*",
)
})
// Wait a bit to ensure no queued messages appear
await new Promise((resolve) => setTimeout(resolve, 100))
// Verify no queued messages appear
expect(queryByTestId("queued-messages")).not.toBeInTheDocument()
})
})

View file

@ -125,6 +125,7 @@
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"addImages": "Add images to message",
"sendMessage": "Send message",
"queueMessage": "Queue message",
"stopTts": "Stop text-to-speech",
"typeMessage": "Type a message...",
"typeTask": "Type your task here...",
@ -353,7 +354,9 @@
"triggerDescription": "Trigger the {{name}} command"
},
"queuedMessages": {
"title": "Queued Messages:",
"clickToEdit": "Click to edit message"
"title": "Queued Messages ({{count}})",
"clickToEdit": "Click to edit message",
"withImages": "{{count}} image(s) attached",
"remove": "Remove from queue"
}
}