diff --git a/webview-ui/src/components/chat/__tests__/ChatView.image-only-edge-case.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.image-only-edge-case.spec.tsx
new file mode 100644
index 0000000000..f006cfd3c5
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/ChatView.image-only-edge-case.spec.tsx
@@ -0,0 +1,445 @@
+// npx vitest run src/components/chat/__tests__/ChatView.image-only-edge-case.spec.tsx
+
+import React from "react"
+import { render, waitFor, act } from "@/utils/test-utils"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+
+import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
+import { vscode } from "@src/utils/vscode"
+
+import ChatView, { ChatViewProps } from "../ChatView"
+
+// Define minimal types needed for testing
+interface ClineMessage {
+ type: "say" | "ask"
+ say?: string
+ ask?: string
+ ts: number
+ text?: string
+ partial?: boolean
+}
+
+interface ExtensionState {
+ version: string
+ clineMessages: ClineMessage[]
+ taskHistory: any[]
+ shouldShowAnnouncement: boolean
+ allowedCommands: string[]
+ alwaysAllowExecute: boolean
+ [key: string]: any
+}
+
+// Mock vscode API
+vi.mock("@src/utils/vscode", () => ({
+ 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 }) {
+ 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 RooCloudCTA component
+vi.mock("@src/components/welcome/RooCloudCTA", () => ({
+ default: function MockRooCloudCTA() {
+ return (
+
+ )
+ },
+}))
+
+// Mock QueuedMessages component - this is the key component for testing
+vi.mock("../QueuedMessages", () => ({
+ default: function MockQueuedMessages({
+ queue = [],
+ onRemove,
+ onUpdate,
+ }: {
+ 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 || "[empty]"}
+ {msg.images.length} images
+
+
+ ))}
+
+ )
+ },
+}))
+
+// 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: (value: string) => void
+ inputValue?: string
+ sendingDisabled?: boolean
+ placeholderText?: string
+ selectedImages?: string[]
+ shouldDisableImages?: boolean
+ onSelectImages?: () => void
+ setSelectedImages?: (images: string[]) => void
+}
+
+const mockInputRef = React.createRef()
+const mockFocus = vi.fn()
+
+// Create a simple mock that can test the core functionality
+vi.mock("../ChatTextArea", () => {
+ const mockReact = require("react")
+
+ return {
+ default: mockReact.forwardRef(function MockChatTextArea(
+ props: ChatTextAreaProps,
+ ref: React.ForwardedRef<{ focus: () => void }>,
+ ) {
+ // Use useImperativeHandle to expose the mock focus method
+ mockReact.useImperativeHandle(ref, () => ({
+ focus: mockFocus,
+ }))
+
+ return (
+
+
+
+
{props.selectedImages?.length || 0}
+
+ )
+ }),
+ }
+})
+
+// Mock VSCode components
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+ VSCodeButton: function MockVSCodeButton({
+ children,
+ onClick,
+ appearance,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ appearance?: string
+ }) {
+ return (
+
+ )
+ },
+ VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) {
+ return {children}
+ },
+}))
+
+// 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 - Image-Only Message Edge Case", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(vscode.postMessage).mockClear()
+ })
+
+ it("handles image-only messages without breaking the queue", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Set up AI busy state to trigger queueing
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true, // This makes sendingDisabled = true
+ },
+ ],
+ })
+
+ // Wait for component to render with AI busy state
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("true")
+ })
+
+ // Clear any initial vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Simulate sending image-only message (empty text + images)
+ // This simulates what happens when a user pastes images but no text
+ const sendImageOnlyButton = getByTestId("send-image-only-button")
+
+ // Simulate the ChatView receiving selectedImages through props
+ // This would normally happen through the ChatTextArea component
+ act(() => {
+ // Trigger the handleSendMessage with empty text and mock images
+ // We'll simulate this by directly calling the onSend with empty string
+ // The real ChatView should handle this gracefully
+ sendImageOnlyButton.click()
+ })
+
+ // The key test: verify that the system doesn't crash or break
+ // Even with empty text, the message should be handled properly
+
+ // Since we're testing the edge case, we mainly want to ensure:
+ // 1. No errors are thrown
+ // 2. The component remains functional
+ // 3. The queue system doesn't break
+
+ // Wait a bit to ensure any async operations complete
+ await waitFor(() => {
+ // The component should still be functional
+ expect(getByTestId("chat-textarea")).toBeInTheDocument()
+ })
+
+ // Verify no errors were thrown and the component is still responsive
+ expect(getByTestId("chat-input")).toBeInTheDocument()
+ expect(getByTestId("send-image-only-button")).toBeInTheDocument()
+ })
+
+ it("processes empty text with images correctly when AI becomes available", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Start with AI busy state
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true, // AI is busy
+ },
+ ],
+ })
+
+ // Wait for busy state
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("true")
+ })
+
+ // Send image-only message while AI is busy
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Clear vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Now simulate AI becoming available (task completes)
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "completion_result",
+ ts: Date.now(),
+ text: "Task completed",
+ partial: false, // AI is no longer busy
+ },
+ ],
+ })
+
+ // Wait for AI to become available
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("false")
+ })
+
+ // The key test: verify that the system handles the transition correctly
+ // and doesn't break when processing queued image-only messages
+
+ // Component should remain functional
+ expect(getByTestId("chat-textarea")).toBeInTheDocument()
+ expect(getByTestId("chat-input")).toBeInTheDocument()
+ })
+
+ it("sends image-only messages immediately when AI is not busy", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Set up state with no active task (AI not busy)
+ mockPostMessage({
+ clineMessages: [], // No active task
+ })
+
+ // Wait for component to render
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("false")
+ })
+
+ // Clear any initial vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Send image-only message when AI is not busy
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // The key test: verify that image-only messages are handled correctly
+ // when sent immediately (not queued)
+
+ // Component should remain functional
+ expect(getByTestId("chat-textarea")).toBeInTheDocument()
+ expect(getByTestId("chat-input")).toBeInTheDocument()
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.image-only-messages.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.image-only-messages.spec.tsx
new file mode 100644
index 0000000000..e19484c49c
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/ChatView.image-only-messages.spec.tsx
@@ -0,0 +1,683 @@
+// npx vitest run src/components/chat/__tests__/ChatView.image-only-messages.spec.tsx
+
+import React from "react"
+import { render, waitFor, act } from "@/utils/test-utils"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+
+import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
+import { vscode } from "@src/utils/vscode"
+
+import ChatView, { ChatViewProps } from "../ChatView"
+
+// Define minimal types needed for testing
+interface ClineMessage {
+ type: "say" | "ask"
+ say?: string
+ ask?: string
+ ts: number
+ text?: string
+ partial?: boolean
+}
+
+interface ExtensionState {
+ version: string
+ clineMessages: ClineMessage[]
+ taskHistory: any[]
+ shouldShowAnnouncement: boolean
+ allowedCommands: string[]
+ alwaysAllowExecute: boolean
+ [key: string]: any
+}
+
+// Mock vscode API
+vi.mock("@src/utils/vscode", () => ({
+ 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 }) {
+ 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 RooCloudCTA component
+vi.mock("@src/components/welcome/RooCloudCTA", () => ({
+ default: function MockRooCloudCTA() {
+ return (
+
+ )
+ },
+}))
+
+// Mock QueuedMessages component to test image-only messages
+vi.mock("../QueuedMessages", () => ({
+ default: function MockQueuedMessages({
+ queue = [],
+ onRemove,
+ onUpdate,
+ }: {
+ 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}
+ {msg.images.length} images
+
+
+
+ ))}
+
+ )
+ },
+}))
+
+// 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: (value: string) => void
+ inputValue?: string
+ sendingDisabled?: boolean
+ placeholderText?: string
+ selectedImages?: string[]
+ shouldDisableImages?: boolean
+ onSelectImages?: () => void
+ setSelectedImages?: (images: string[]) => void
+}
+
+const mockInputRef = React.createRef()
+const mockFocus = vi.fn()
+
+// Create a more sophisticated mock that can simulate image-only messages
+vi.mock("../ChatTextArea", () => {
+ const mockReact = require("react")
+
+ return {
+ default: mockReact.forwardRef(function MockChatTextArea(
+ props: ChatTextAreaProps,
+ ref: React.ForwardedRef<{ focus: () => void }>,
+ ) {
+ const [localInputValue, setLocalInputValue] = mockReact.useState(props.inputValue || "")
+ const [localSelectedImages, setLocalSelectedImages] = mockReact.useState(props.selectedImages || [])
+
+ // Use useImperativeHandle to expose the mock focus method
+ mockReact.useImperativeHandle(ref, () => ({
+ focus: mockFocus,
+ }))
+
+ // Sync with parent props
+ mockReact.useEffect(() => {
+ setLocalInputValue(props.inputValue || "")
+ }, [props.inputValue])
+
+ mockReact.useEffect(() => {
+ setLocalSelectedImages(props.selectedImages || [])
+ }, [props.selectedImages])
+
+ return (
+
+
{
+ setLocalInputValue(e.target.value)
+ }}
+ data-sending-disabled={props.sendingDisabled}
+ data-testid="chat-input"
+ />
+
+
+
+
{localSelectedImages.length}
+
+ )
+ }),
+ }
+})
+
+// Mock VSCode components
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+ VSCodeButton: function MockVSCodeButton({
+ children,
+ onClick,
+ appearance,
+ }: {
+ children: React.ReactNode
+ onClick?: () => void
+ appearance?: string
+ }) {
+ return (
+
+ )
+ },
+ VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) {
+ return {children}
+ },
+}))
+
+// 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 - Image-Only Message Tests", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(vscode.postMessage).mockClear()
+ })
+
+ it("handles image-only messages correctly when AI is busy", async () => {
+ const { getByTestId } = renderChatView()
+
+ // First hydrate state with initial task that makes AI busy
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true, // This makes sendingDisabled = true
+ },
+ ],
+ })
+
+ // Wait for component to render with AI busy state
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("true")
+ })
+
+ // Clear any initial vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Simulate adding images
+ const addImagesButton = getByTestId("add-images-button")
+ act(() => {
+ addImagesButton.click()
+ })
+
+ // Wait for images to be added
+ await waitFor(() => {
+ expect(getByTestId("selected-images-count")).toHaveTextContent("2")
+ })
+
+ // Simulate sending image-only message (empty text + images)
+ const sendImageOnlyButton = getByTestId("send-image-only-button")
+ act(() => {
+ sendImageOnlyButton.click()
+ })
+
+ // Wait for the message to be queued (not sent immediately since AI is busy)
+ await waitFor(() => {
+ expect(getByTestId("queued-messages")).toBeInTheDocument()
+ })
+
+ // Verify the queued message has empty text but images
+ expect(getByTestId("message-text-0")).toHaveTextContent("") // Empty text
+ expect(getByTestId("message-images-0")).toHaveTextContent("2 images") // Has images
+
+ // Verify no immediate vscode message was sent (because AI is busy)
+ expect(vscode.postMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "newTask",
+ }),
+ )
+ expect(vscode.postMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "askResponse",
+ }),
+ )
+ })
+
+ it("processes image-only messages from queue when AI becomes available", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Start with AI busy state and queue an image-only message
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true, // AI is busy
+ },
+ ],
+ })
+
+ // Wait for busy state
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("true")
+ })
+
+ // Add images and send image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ await waitFor(() => {
+ expect(getByTestId("selected-images-count")).toHaveTextContent("2")
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Verify message is queued
+ await waitFor(() => {
+ expect(getByTestId("queued-messages")).toBeInTheDocument()
+ expect(getByTestId("message-text-0")).toHaveTextContent("")
+ expect(getByTestId("message-images-0")).toHaveTextContent("2 images")
+ })
+
+ // Clear vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Now simulate AI becoming available (task completes)
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "completion_result",
+ ts: Date.now(),
+ text: "Task completed",
+ partial: false, // AI is no longer busy
+ },
+ ],
+ })
+
+ // Wait for AI to become available and queue to process
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("false")
+ })
+
+ // Wait for the queued message to be processed
+ await waitFor(
+ () => {
+ expect(vscode.postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "askResponse",
+ askResponse: "messageResponse",
+ text: "", // Empty text
+ images: ["data:image/png;base64,test1", "data:image/png;base64,test2"], // But has images
+ }),
+ )
+ },
+ { timeout: 2000 },
+ )
+ })
+
+ it("allows editing image-only messages in the queue", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Set up AI busy state
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true,
+ },
+ ],
+ })
+
+ // Add images and send image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Wait for message to be queued
+ await waitFor(() => {
+ expect(getByTestId("queued-messages")).toBeInTheDocument()
+ expect(getByTestId("message-text-0")).toHaveTextContent("")
+ })
+
+ // Edit the queued message to add text
+ const editButton = getByTestId("edit-button-0")
+ act(() => {
+ editButton.click()
+ })
+
+ // Verify the message text was updated
+ await waitFor(() => {
+ expect(getByTestId("message-text-0")).toHaveTextContent("edited text")
+ })
+ })
+
+ it("allows removing image-only messages from the queue", async () => {
+ const { getByTestId, queryByTestId } = renderChatView()
+
+ // Set up AI busy state
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true,
+ },
+ ],
+ })
+
+ // Add images and send image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Wait for message to be queued
+ await waitFor(() => {
+ expect(getByTestId("queued-messages")).toBeInTheDocument()
+ })
+
+ // Remove the queued message
+ const removeButton = getByTestId("remove-button-0")
+ act(() => {
+ removeButton.click()
+ })
+
+ // Verify the queue is now empty
+ await waitFor(() => {
+ expect(queryByTestId("queued-messages")).not.toBeInTheDocument()
+ })
+ })
+
+ it("handles multiple image-only messages in queue correctly", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Set up AI busy state
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true,
+ },
+ ],
+ })
+
+ // Add and send first image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Add and send second image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Wait for both messages to be queued
+ await waitFor(() => {
+ expect(getByTestId("queued-messages")).toBeInTheDocument()
+ expect(getByTestId("queued-message-0")).toBeInTheDocument()
+ expect(getByTestId("queued-message-1")).toBeInTheDocument()
+ })
+
+ // Verify both messages have empty text but images
+ expect(getByTestId("message-text-0")).toHaveTextContent("")
+ expect(getByTestId("message-images-0")).toHaveTextContent("2 images")
+ expect(getByTestId("message-text-1")).toHaveTextContent("")
+ expect(getByTestId("message-images-1")).toHaveTextContent("2 images")
+ })
+
+ it("sends image-only messages immediately when AI is not busy", async () => {
+ const { getByTestId, queryByTestId } = renderChatView()
+
+ // Set up state with no active task (AI not busy)
+ mockPostMessage({
+ clineMessages: [], // No active task
+ })
+
+ // Wait for component to render
+ await waitFor(() => {
+ const chatInput = getByTestId("chat-input")
+ expect(chatInput.getAttribute("data-sending-disabled")).toBe("false")
+ })
+
+ // Clear any initial vscode calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add images and send image-only message
+ act(() => {
+ getByTestId("add-images-button").click()
+ })
+
+ await waitFor(() => {
+ expect(getByTestId("selected-images-count")).toHaveTextContent("2")
+ })
+
+ act(() => {
+ getByTestId("send-image-only-button").click()
+ })
+
+ // Verify message is sent immediately (not queued) since AI is not busy
+ await waitFor(() => {
+ expect(vscode.postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "newTask",
+ text: "", // Empty text
+ images: ["data:image/png;base64,test1", "data:image/png;base64,test2"], // But has images
+ }),
+ )
+ })
+
+ // Verify no queue is shown
+ expect(queryByTestId("queued-messages")).not.toBeInTheDocument()
+ })
+})