From 7dd7949f75a0c9db5e68de6bd1d83cab44eb8dec Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 10 Feb 2026 17:19:27 +0000 Subject: [PATCH] fix: preserve pasted images when api_req_started fires during chat activity --- webview-ui/src/components/chat/ChatView.tsx | 6 +- .../ChatView.preserve-images.spec.tsx | 485 ++++++++++++++++++ 2 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index ea6a7a45ac9..3df10e4e996 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -456,7 +456,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound hook +const mockPlayFunction = vi.fn() +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => { + return [mockPlayFunction] + }), +})) + +// Mock components that use ESM dependencies +vi.mock("../BrowserSessionRow", () => ({ + default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { + return
{JSON.stringify(messages)}
+ }, +})) + +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: ClineMessage }) { + return
{JSON.stringify(message)}
+ }, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +// Mock VersionIndicator +vi.mock("../../common/VersionIndicator", () => ({ + default: vi.fn(() => null), +})) + +vi.mock("../Announcement", () => ({ + default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require("react") + return React.createElement( + "div", + { "data-testid": "announcement-modal" }, + React.createElement("div", null, "What's New"), + React.createElement("button", { onClick: hideAnnouncement }, "Close"), + ) + }, +})) + +// Mock DismissibleUpsell component +vi.mock("@/components/common/DismissibleUpsell", () => ({ + default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) { + return
{children}
+ }, +})) + +// Mock QueuedMessages component +vi.mock("../QueuedMessages", () => ({ + QueuedMessages: function MockQueuedMessages({ + queue = [], + onRemove, + }: { + queue?: Array<{ id: string; text: string; images?: string[] }> + onRemove?: (index: number) => void + onUpdate?: (index: number, newText: string) => void + }) { + if (!queue || queue.length === 0) { + return null + } + return ( +
+ {queue.map((msg, index) => ( +
+ {msg.text} + +
+ ))} +
+ ) + }, +})) + +// Mock RooTips component +vi.mock("@src/components/welcome/RooTips", () => ({ + default: function MockRooTips() { + return
Tips content
+ }, +})) + +// Mock RooHero component +vi.mock("@src/components/welcome/RooHero", () => ({ + default: function MockRooHero() { + return
Hero content
+ }, +})) + +// Mock TelemetryBanner component +vi.mock("../common/TelemetryBanner", () => ({ + default: function MockTelemetryBanner() { + return null + }, +})) + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: any) => { + if (key === "chat:versionIndicator.ariaLabel" && options?.version) { + return `Version ${options.version}` + } + return key + }, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { + return <>{children || i18nKey} + }, +})) + +interface ChatTextAreaProps { + onSend: () => void + inputValue?: string + setInputValue?: (value: string) => void + sendingDisabled?: boolean + placeholderText?: string + selectedImages?: string[] + setSelectedImages?: React.Dispatch> + shouldDisableImages?: boolean +} + +const mockInputRef = React.createRef() +const mockFocus = vi.fn() + +// Mock ChatTextArea to expose selectedImages via a data attribute +vi.mock("../ChatTextArea", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mockReact = require("react") + + const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea( + props: ChatTextAreaProps, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + mockReact.useImperativeHandle(ref, () => ({ + focus: mockFocus, + })) + + return ( +
+ { + if (props.setInputValue) { + props.setInputValue(e.target.value) + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + props.onSend() + } + }} + data-sending-disabled={props.sendingDisabled} + /> +
+ ) + }) + + return { + default: ChatTextAreaComponent, + ChatTextArea: ChatTextAreaComponent, + } +}) + +// Mock react-virtuoso +vi.mock("react-virtuoso", () => ({ + Virtuoso: function MockVirtuoso({ + data, + itemContent, + }: { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + }) { + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }, +})) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: Partial) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) +} + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const queryClient = new QueryClient() + +const renderChatView = (props: Partial = {}) => { + return render( + + + + + , + ) +} + +describe("ChatView - Preserve Images During Chat Activity", () => { + beforeEach(() => vi.clearAllMocks()) + + it("should not clear selectedImages when api_req_started message arrives", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with an active task + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + ], + }) + }) + + // Wait for the component to render + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Simulate user pasting an image via the selectedImages message + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + ], + }, + "*", + ) + }) + + // Verify images are set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + }) + + // Now simulate an api_req_started message (which happens during chat activity) + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + { + type: "say", + say: "api_req_started", + ts: Date.now(), + text: JSON.stringify({ request: "test" }), + }, + ], + }) + }) + + // Images should still be present after api_req_started + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + expect(images[0]).toContain("data:image/png;base64,") + }) + }) + + it("should preserve images through multiple api_req_started messages", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with an active task + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + ], + }) + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Simulate user pasting two images + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: ["data:image/png;base64,image1", "data:image/png;base64,image2"], + }, + "*", + ) + }) + + // Verify both images are set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(2) + }) + + // Simulate multiple api_req_started messages (multiple API calls during task processing) + const baseTs = Date.now() + for (let i = 0; i < 3; i++) { + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: baseTs - 5000, + text: "Initial task", + }, + { + type: "say", + say: "api_req_started", + ts: baseTs + i * 1000, + text: JSON.stringify({ request: `test-${i}` }), + }, + ], + }) + }) + } + + // Images should still be preserved after multiple api_req_started messages + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(2) + expect(images[0]).toBe("data:image/png;base64,image1") + expect(images[1]).toBe("data:image/png;base64,image2") + }) + }) + + it("should still clear images when user sends a message", async () => { + const { getByTestId } = renderChatView() + + // Hydrate with an active task that has a followup ask (so sending is enabled) + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + { + type: "ask", + ask: "followup", + ts: Date.now(), + text: "What do you want to do?", + }, + ], + }) + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Add an image + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: ["data:image/png;base64,testimage"], + }, + "*", + ) + }) + + // Verify image is set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + }) + + // Type something and send (Enter key triggers onSend -> handleSendMessage) + const input = mockInputRef.current! + await act(async () => { + // Set input value first + input.focus() + // Fire change event to set the input value + input.value = "Here is my image" + input.dispatchEvent(new Event("change", { bubbles: true })) + }) + + await act(async () => { + // Press Enter to send + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })) + }) + + // After sending, images should be cleared + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(0) + }) + }) +})