mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
342ee70fb4
commit
7470c570ff
4 changed files with 298 additions and 48 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue