fix: queue messages when button approval is pending (tool/command/mcp)

Fixes #10675 - Messages now queue when approval buttons are shown for:
- tool (file edits: Save/Reject)
- command (command execution: Run Command/Reject)
- browser_action_launch (browser actions: Approve/Reject)
- use_mcp_server (MCP server usage: Approve/Reject)
- command_output (command running: Proceed While Running/Kill Command)

Previously, messages sent during these states were lost because they were
sent as askResponse but the backend expected button clicks. Now messages
are properly queued and processed after the approval interaction completes.
This commit is contained in:
Roo Code 2026-01-14 05:41:13 +00:00
parent d689de34f8
commit ec5cf17d19
2 changed files with 200 additions and 2 deletions

View file

@ -62,6 +62,16 @@ export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
// Ask types that display approval buttons where typed messages should be queued
// rather than sent directly as askResponse (which would be lost/ignored).
const BUTTON_APPROVAL_ASK_TYPES: ClineAsk[] = [
"tool", // File edits: Save/Reject
"command", // Command execution: Run Command/Reject
"browser_action_launch", // Browser actions: Approve/Reject
"use_mcp_server", // MCP server usage: Approve/Reject
"command_output", // Command running: Proceed While Running/Kill Command
]
const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewProps> = (
{ isHidden, showAnnouncement, hideAnnouncement },
ref,
@ -587,7 +597,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// - Task is busy (sendingDisabled)
// - API request in progress (isStreaming)
// - Queue has items (preserve message order during drain)
if (sendingDisabled || isStreaming || messageQueue.length > 0) {
// - Waiting for button approval (tool/command/browser/mcp/command_output)
const isWaitingForButtonApproval =
clineAskRef.current !== undefined &&
BUTTON_APPROVAL_ASK_TYPES.includes(clineAskRef.current) &&
enableButtons
if (sendingDisabled || isStreaming || messageQueue.length > 0 || isWaitingForButtonApproval) {
try {
console.log("queueMessage", text, images)
vscode.postMessage({ type: "queueMessage", text, images })
@ -643,7 +659,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length], // messagesRef and clineAskRef are stable
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length, enableButtons], // messagesRef and clineAskRef are stable
)
const handleSetChatBoxMessage = useCallback(

View file

@ -1081,6 +1081,188 @@ describe("ChatView - Message Queueing Tests", () => {
}),
)
})
it("queues messages when tool approval buttons are shown (tool state) - Issue #10675", async () => {
const { getByTestId, container } = renderChatView()
// Hydrate state with a tool ask (file edit approval)
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "editedExistingFile", path: "test.txt", diff: "some diff" }),
partial: false, // Buttons are shown when not partial
},
],
})
// Wait for buttons to become enabled (indicates enableButtons = true)
await waitFor(() => {
const buttons = container.querySelectorAll("button")
const saveButton = Array.from(buttons).find((btn) => btn.textContent?.includes("chat:save.title"))
expect(saveButton).toBeTruthy()
expect(saveButton).not.toHaveAttribute("disabled")
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user typing and sending a message while Save/Reject buttons are shown
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { value: "additional context for later" } })
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the message was queued, not sent as askResponse
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "queueMessage",
text: "additional context for later",
images: [],
})
})
// Verify it was NOT sent as askResponse (which would cause the message to be lost)
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "askResponse",
askResponse: "messageResponse",
}),
)
})
it("queues messages when command approval buttons are shown (command state)", async () => {
const { getByTestId, container } = renderChatView()
// Hydrate state with a command ask (command approval)
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command",
ts: Date.now(),
text: "npm install",
partial: false, // Buttons are shown when not partial
},
],
})
// Wait for buttons to become enabled (indicates enableButtons = true)
await waitFor(() => {
const buttons = container.querySelectorAll("button")
const runButton = Array.from(buttons).find((btn) => btn.textContent?.includes("chat:runCommand.title"))
expect(runButton).toBeTruthy()
expect(runButton).not.toHaveAttribute("disabled")
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user typing and sending a message while Run Command/Reject buttons are shown
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { value: "wait, let me think about this" } })
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the message was queued, not sent as askResponse
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "queueMessage",
text: "wait, let me think about this",
images: [],
})
})
// Verify it was NOT sent as askResponse
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "askResponse",
askResponse: "messageResponse",
}),
)
})
it("queues messages when command is running (command_output state) - Issue #10675", async () => {
const { getByTestId, container } = renderChatView()
// Hydrate state with a command_output ask (command running)
mockPostMessage({
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "command_output",
ts: Date.now(),
text: "Running sleep 60...",
partial: false,
},
],
})
// Wait for buttons to become enabled (indicates enableButtons = true)
await waitFor(() => {
const buttons = container.querySelectorAll("button")
const proceedButton = Array.from(buttons).find((btn) =>
btn.textContent?.includes("chat:proceedWhileRunning.title"),
)
expect(proceedButton).toBeTruthy()
expect(proceedButton).not.toHaveAttribute("disabled")
})
// Clear message calls before simulating user input
vi.mocked(vscode.postMessage).mockClear()
// Simulate user typing and sending a message while Proceed While Running/Kill Command buttons are shown
const chatTextArea = getByTestId("chat-textarea")
const input = chatTextArea.querySelector("input")! as HTMLInputElement
await act(async () => {
fireEvent.change(input, { target: { value: "clarifying message for later" } })
fireEvent.keyDown(input, { key: "Enter", code: "Enter" })
})
// Verify that the message was queued, not sent as askResponse
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "queueMessage",
text: "clarifying message for later",
images: [],
})
})
// Verify it was NOT sent as askResponse (which would cause the message to be lost)
expect(vscode.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "askResponse",
askResponse: "messageResponse",
}),
)
})
})
describe("ChatView - Context Condensing Indicator Tests", () => {