From ec5cf17d1947fd4d7a64a63dd6b368bda5c7a3ac Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 14 Jan 2026 05:41:13 +0000 Subject: [PATCH] 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. --- webview-ui/src/components/chat/ChatView.tsx | 20 +- .../chat/__tests__/ChatView.spec.tsx | 182 ++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 8f34de2cda3..322cc994b4d 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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 = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, @@ -587,7 +597,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction 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 { }), ) }) + + 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", () => {