diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 784540e06f..19ec8039ea 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -181,6 +181,10 @@ "task_prompt": "What should Roo do?", "task_placeholder": "Type your task here" }, + "chat": { + "quotePreview": "Quote", + "quoteSelection": "Quote" + }, "customModes": { "errors": { "yamlParseError": "Invalid YAML in .roomodes file at line {{line}}. Please check for:\n• Proper indentation (use spaces, not tabs)\n• Matching quotes and brackets\n• Valid YAML syntax", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 42adea6d39..bab670f469 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -70,6 +70,7 @@ export interface ExtensionMessage { | "theme" | "workspaceUpdated" | "invoke" + | "addQuoteToComposer" | "messageUpdated" | "mcpServers" | "enhancedPrompt" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index e460f20384..65d29550bb 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -35,6 +35,7 @@ export interface WebviewMessage { | "deleteApiConfiguration" | "loadApiConfiguration" | "loadApiConfigurationById" + | "addQuoteToComposer" | "renameApiConfiguration" | "getListApiConfiguration" | "customInstructions" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ed5257528f..322d4c04ea 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1092,7 +1092,7 @@ export const ChatRowContent = ({ {t("chat:text.rooSaid")}
- + {message.images && message.images.length > 0 && (
{message.images.map((image, index) => ( @@ -1201,7 +1201,7 @@ export const ChatRowContent = ({ {title}
- +
) @@ -1355,7 +1355,7 @@ export const ChatRowContent = ({
)}
- +
) @@ -1441,7 +1441,11 @@ export const ChatRowContent = ({ {title}
- +
) @@ -1460,6 +1464,7 @@ export const ChatRowContent = ({
void + // Quote selection props + quotedText?: string + onClearQuote?: () => void } export const ChatTextArea = forwardRef( @@ -71,6 +74,8 @@ export const ChatTextArea = forwardRef( modeShortcutText, isEditMode = false, onCancel, + quotedText, + onClearQuote, }, ref, ) => { @@ -913,6 +918,31 @@ export const ChatTextArea = forwardRef( "flex flex-col gap-1 bg-editor-background outline-none border border-none box-border", isEditMode ? "p-2 w-full" : "relative px-1.5 pb-1 w-[calc(100%-16px)] ml-auto mr-auto", )}> + {/* Quote preview above text area */} + {quotedText && !isEditMode && ( +
+ +
{t("chat:quotePreview")}
+
+ {quotedText + .split("\n") + .slice(0, 3) + .map((line, idx) => ( +
+ {line} +
+ ))} + {quotedText.split("\n").length > 3 && ( +
...
+ )} +
+
+ )}
(undefined) const [isCondensing, setIsCondensing] = useState(false) const [showAnnouncementModal, setShowAnnouncementModal] = useState(false) + const [quotedText, setQuotedText] = useState("") const everVisibleMessagesTsRef = useRef>( new LRUCache({ max: 100, @@ -783,6 +784,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction `> ${line}`) + .join("\n")}\n[/context]\n` + setQuotedText(formattedQuote) + } + break case "action": switch (message.action!) { case "didBecomeVisible": @@ -1720,6 +1732,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Check for Command/Ctrl + Shift + Q for quote selection + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "q") { + event.preventDefault() + + // Get current text selection + const selection = window.getSelection() + if (selection && !selection.isCollapsed) { + const selectedText = selection.toString().trim() + if (selectedText) { + // Format the quoted text with [context] wrapper + const formattedQuote = `[context]\n${selectedText + .split("\n") + .map((line) => `> ${line}`) + .join("\n")}\n[/context]\n` + setQuotedText(formattedQuote) + + // Clear the selection + selection.removeAllRanges() + } + } + } + // Check for Command/Ctrl + Period (with or without Shift) // Using event.key to respect keyboard layouts (e.g., Dvorak) if ((event.metaKey || event.ctrlKey) && event.key === ".") { @@ -1992,7 +2026,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction handleSendMessage(inputValue, selectedImages)} + onSend={() => { + // Include quoted text when sending + const finalMessage = quotedText ? quotedText + inputValue : inputValue + handleSendMessage(finalMessage, selectedImages) + setQuotedText("") // Clear quote after sending + }} onSelectImages={selectImages} shouldDisableImages={shouldDisableImages} onHeightChange={() => { @@ -2003,6 +2042,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction setQuotedText("")} /> {isProfileDisabled && ( diff --git a/webview-ui/src/components/chat/Markdown.tsx b/webview-ui/src/components/chat/Markdown.tsx index 87780d5df8..eaef256436 100644 --- a/webview-ui/src/components/chat/Markdown.tsx +++ b/webview-ui/src/components/chat/Markdown.tsx @@ -1,67 +1,179 @@ -import { memo, useState } from "react" +import { memo, useState, useEffect, useRef } from "react" import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { MessageSquareQuote } from "lucide-react" import { useCopyToClipboard } from "@src/utils/clipboard" import { StandardTooltip } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" import MarkdownBlock from "../common/MarkdownBlock" -export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => { - const [isHovering, setIsHovering] = useState(false) +export const Markdown = memo( + ({ markdown, partial, messageTs }: { markdown?: string; partial?: boolean; messageTs?: number }) => { + const [isHovering, setIsHovering] = useState(false) + const [selectedText, setSelectedText] = useState("") + const [quoteButtonPosition, setQuoteButtonPosition] = useState<{ top: number; left: number } | null>(null) + const containerRef = useRef(null) - // Shorter feedback duration for copy button flash. - const { copyWithFeedback } = useCopyToClipboard(200) + // Shorter feedback duration for copy button flash. + const { copyWithFeedback } = useCopyToClipboard(200) - if (!markdown || markdown.length === 0) { - return null - } + // Handle text selection + useEffect(() => { + const handleSelectionChange = () => { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || !containerRef.current) { + setSelectedText("") + setQuoteButtonPosition(null) + return + } - return ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - style={{ position: "relative" }}> -
- -
- {markdown && !partial && isHovering && ( -
- - - { - const success = await copyWithFeedback(markdown) - if (success) { - const button = document.activeElement as HTMLElement - if (button) { - button.style.background = "var(--vscode-button-background)" - setTimeout(() => { - button.style.background = "" - }, 200) - } - } - }}> - - - + // Check if selection is within our container + const range = selection.getRangeAt(0) + const commonAncestor = range.commonAncestorContainer + const isInContainer = containerRef.current.contains(commonAncestor) + + if (isInContainer) { + const text = selection.toString().trim() + if (text) { + setSelectedText(text) + + // Get the selection bounds to position the quote button + const rect = range.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() + + // Position the button above and centered on the selection + setQuoteButtonPosition({ + top: rect.top - containerRect.top - 36, // 36px above selection + left: rect.left - containerRect.left + rect.width / 2 - 16, // centered (button is ~32px wide) + }) + } else { + setSelectedText("") + setQuoteButtonPosition(null) + } + } else { + setSelectedText("") + setQuoteButtonPosition(null) + } + } + + // Listen for selection changes + document.addEventListener("selectionchange", handleSelectionChange) + + return () => { + document.removeEventListener("selectionchange", handleSelectionChange) + } + }, []) + + const handleQuoteClick = () => { + if (selectedText) { + // Send message to add quote to composer + vscode.postMessage({ + type: "addQuoteToComposer", + text: selectedText, + messageTs: messageTs, + }) + + // Clear selection + window.getSelection()?.removeAllRanges() + setSelectedText("") + setQuoteButtonPosition(null) + } + } + + if (!markdown || markdown.length === 0) { + return null + } + + return ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + style={{ position: "relative" }}> +
+
- )} -
- ) -}) + + {/* Quote button that appears on text selection */} + {selectedText && quoteButtonPosition && !partial && ( +
+ + + +
+ )} + + {/* Copy button that appears on hover */} + {markdown && !partial && isHovering && ( +
+ + + { + const success = await copyWithFeedback(markdown) + if (success) { + const button = document.activeElement as HTMLElement + if (button) { + button.style.background = "var(--vscode-button-background)" + setTimeout(() => { + button.style.background = "" + }, 200) + } + } + }}> + + + +
+ )} +
+ ) + }, +) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index af7704aa1b..cfca1740df 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -1,11 +1,48 @@ -import { defaultModeSlug } from "@roo/modes" - -import { render, fireEvent, screen } from "@src/utils/test-utils" -import { useExtensionState } from "@src/context/ExtensionStateContext" -import { vscode } from "@src/utils/vscode" -import * as pathMentions from "@src/utils/path-mentions" - +import { render, fireEvent, screen } from "@testing-library/react" +import { vi, describe, it, expect, beforeEach } from "vitest" import { ChatTextArea } from "../ChatTextArea" +import React from "react" + +// Mock the Mode type +const mockMode = "code" + +// Mock dependencies +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + filePaths: [], + openedTabs: [], + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "test-id" }], + customModes: [], + customModePrompts: {}, + cwd: "/test", + pinnedApiConfigs: {}, + togglePinnedApiConfig: vi.fn(), + taskHistory: [], + clineMessages: [], + commands: [], + cloudUserInfo: null, + }), +})) + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => { + const translations: { [key: string]: string } = { + "chat:quotePreview": "Quote", + "chat:enhancePrompt": "Enhance prompt", + "chat:addImages": "Add images", + "chat:sendMessage": "Send message", + "chat:selectMode": "Select mode", + "chat:selectApiConfig": "Select API config", + "chat:addContext": "Add context", + "chat:dragFiles": "drag files", + "chat:dragFilesImages": "drag files and images", + } + return translations[key] || key + }, + }), +})) vi.mock("@src/utils/vscode", () => ({ vscode: { @@ -13,1130 +50,158 @@ vi.mock("@src/utils/vscode", () => ({ }, })) -vi.mock("@src/components/common/CodeBlock") -vi.mock("@src/components/common/MarkdownBlock") -vi.mock("@src/utils/path-mentions", () => ({ - convertToMentionPath: vi.fn((path, cwd) => { - // Simple mock implementation that mimics the real function's behavior - if (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) { - const relativePath = path.substring(cwd.length) - return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath) - } - return path - }), +vi.mock("@roo/modes", () => ({ + getAllModes: () => [], })) -// Get the mocked postMessage function -const mockPostMessage = vscode.postMessage as ReturnType -const mockConvertToMentionPath = pathMentions.convertToMentionPath as ReturnType +// Mock additional components +vi.mock("../ModeSelector", () => ({ + ModeSelector: () =>
Mode Selector
, +})) -// Mock ExtensionStateContext -vi.mock("@src/context/ExtensionStateContext") +vi.mock("../ApiConfigSelector", () => ({ + ApiConfigSelector: () =>
API Config Selector
, +})) -// Custom query function to get the enhance prompt button -const getEnhancePromptButton = () => { - return screen.getByRole("button", { - name: (_, element) => { - // Find the button with the wand sparkles icon (Lucide React) - return element.querySelector(".lucide-wand-sparkles") !== null - }, - }) -} +vi.mock("../AutoApproveDropdown", () => ({ + AutoApproveDropdown: () =>
Auto Approve Dropdown
, +})) -describe("ChatTextArea", () => { - const defaultProps = { - inputValue: "", - setInputValue: vi.fn(), - onSend: vi.fn(), - sendingDisabled: false, - selectApiConfigDisabled: false, - onSelectImages: vi.fn(), - shouldDisableImages: false, - placeholderText: "Type a message...", - selectedImages: [], - setSelectedImages: vi.fn(), - onHeightChange: vi.fn(), - mode: defaultModeSlug, - setMode: vi.fn(), - modeShortcutText: "(⌘. for next mode)", - } +vi.mock("../IndexingStatusBadge", () => ({ + IndexingStatusBadge: () =>
Indexing Status Badge
, +})) + +vi.mock("../../cloud/CloudAccountSwitcher", () => ({ + CloudAccountSwitcher: () =>
Cloud Account Switcher
, +})) + +vi.mock("../../../components/common/Thumbnails", () => ({ + default: () =>
Thumbnails
, +})) + +describe("ChatTextArea Quote Preview", () => { + const mockSetInputValue = vi.fn() + const mockOnSend = vi.fn() + const mockOnClearQuote = vi.fn() + const mockSetSelectedImages = vi.fn() + const mockOnSelectImages = vi.fn() + const mockSetMode = vi.fn() beforeEach(() => { vi.clearAllMocks() - // Default mock implementation for useExtensionState - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - cwd: "/test/workspace", - }) }) - describe("enhance prompt button", () => { - it("should be enabled even when sendingDisabled is true (for message queueing)", () => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - taskHistory: [], - cwd: "/test/workspace", - }) - render() - const enhanceButton = getEnhancePromptButton() - expect(enhanceButton).toHaveClass("cursor-pointer") - }) + it("should display quote preview when quotedText is provided", () => { + const quotedText = "This is a quoted message" + + render( + , + ) + + // Check if quote preview is displayed + expect(screen.getByText("Quote")).toBeInTheDocument() + expect(screen.getByText(quotedText)).toBeInTheDocument() }) - describe("handleEnhancePrompt", () => { - it("should send message with correct configuration when clicked", () => { - const apiConfiguration = { - apiProvider: "openrouter", - apiKey: "test-key", - } + it("should not display quote preview when quotedText is empty", () => { + render( + , + ) - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration, - taskHistory: [], - cwd: "/test/workspace", - }) - - render() - - const enhanceButton = getEnhancePromptButton() - fireEvent.click(enhanceButton) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "enhancePrompt", - text: "Test prompt", - }) - }) - - it("should not send message when input is empty", () => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "openrouter", - }, - taskHistory: [], - cwd: "/test/workspace", - }) - - render() - - // Clear any calls from component initialization (e.g., IndexingStatusBadge) - mockPostMessage.mockClear() - - const enhanceButton = getEnhancePromptButton() - fireEvent.click(enhanceButton) - - expect(mockPostMessage).not.toHaveBeenCalled() - }) - - it("should show loading state while enhancing", () => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "openrouter", - }, - taskHistory: [], - cwd: "/test/workspace", - }) - - render() - - const enhanceButton = getEnhancePromptButton() - fireEvent.click(enhanceButton) - - // Check if the WandSparkles icon has the animate-spin class - const animatingIcon = enhanceButton.querySelector(".animate-spin") - expect(animatingIcon).toBeInTheDocument() - }) + // Check that quote preview is not displayed + expect(screen.queryByText("Quote")).not.toBeInTheDocument() }) - describe("effect dependencies", () => { - it("should update when apiConfiguration changes", () => { - const { rerender } = render() + it("should call onClearQuote when dismiss button is clicked", () => { + const quotedText = "This is a quoted message" - // Update apiConfiguration - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "openrouter", - newSetting: "test", - }, - taskHistory: [], - cwd: "/test/workspace", - }) + const { container } = render( + , + ) - rerender() + // Find and click the dismiss button + const dismissButton = container.querySelector('[aria-label="Clear quote"]') + expect(dismissButton).toBeInTheDocument() + fireEvent.click(dismissButton!) - // Verify the enhance button appears after apiConfiguration changes - expect(getEnhancePromptButton()).toBeInTheDocument() - }) + // Check that onClearQuote was called + expect(mockOnClearQuote).toHaveBeenCalledTimes(1) }) - describe("enhanced prompt response", () => { - it("should update input value using native browser methods when receiving enhanced prompt", () => { - const setInputValue = vi.fn() - - // Mock document.execCommand - const mockExecCommand = vi.fn().mockReturnValue(true) - Object.defineProperty(document, "execCommand", { - value: mockExecCommand, - writable: true, - }) - - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Mock textarea methods - const mockSelect = vi.fn() - const mockFocus = vi.fn() - textarea.select = mockSelect - textarea.focus = mockFocus - - // Simulate receiving enhanced prompt message - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "enhancedPrompt", - text: "Enhanced test prompt", - }, - }), - ) - - // Verify native browser methods were used - expect(mockFocus).toHaveBeenCalled() - expect(mockSelect).toHaveBeenCalled() - expect(mockExecCommand).toHaveBeenCalledWith("insertText", false, "Enhanced test prompt") - }) - - it("should fallback to setInputValue when execCommand is not available", () => { - const setInputValue = vi.fn() - - // Mock document.execCommand to be undefined (not available) - Object.defineProperty(document, "execCommand", { - value: undefined, - writable: true, - }) - - render() - - // Simulate receiving enhanced prompt message - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "enhancedPrompt", - text: "Enhanced test prompt", - }, - }), - ) - - // Verify fallback to setInputValue was used - expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt") - }) - - it("should not crash when textarea ref is not available", () => { - const setInputValue = vi.fn() - - render() - - // Simulate receiving enhanced prompt message when textarea ref might not be ready - expect(() => { - window.dispatchEvent( - new MessageEvent("message", { - data: { - type: "enhancedPrompt", - text: "Enhanced test prompt", - }, - }), - ) - }).not.toThrow() - }) - }) - - describe("multi-file drag and drop", () => { - const mockCwd = "/Users/test/project" - - beforeEach(() => { - vi.clearAllMocks() - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - cwd: mockCwd, - }) - mockConvertToMentionPath.mockClear() - }) - - it("should process multiple file paths separated by newlines", () => { - const setInputValue = vi.fn() - - const { container } = render( - , - ) - - // Create a mock dataTransfer object with text data containing multiple file paths - const dataTransfer = { - getData: vi.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was called for each file path - expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2) - expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd) - expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd) - - // Verify setInputValue was called with the correct value - // The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js - expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text") - }) - - it("should filter out empty lines in the dragged text", () => { - const setInputValue = vi.fn() - - const { container } = render( - , - ) - - // Create a mock dataTransfer object with text data containing empty lines - const dataTransfer = { - getData: vi.fn().mockReturnValue("/Users/test/project/file1.js\n\n/Users/test/project/file2.js\n\n"), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was called only for non-empty lines - expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2) - - // Verify setInputValue was called with the correct value - expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text") - }) - - it("should correctly update cursor position after adding multiple mentions", () => { - const setInputValue = vi.fn() - const initialCursorPosition = 5 - - const { container } = render( - , - ) - - // Set the cursor position manually - const textArea = container.querySelector("textarea") - if (textArea) { - textArea.selectionStart = initialCursorPosition - textArea.selectionEnd = initialCursorPosition - } - - // Create a mock dataTransfer object with text data - const dataTransfer = { - getData: vi.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // The cursor position should be updated based on the implementation in the component - expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Hello world") - }) - - it("should handle very long file paths correctly", () => { - const setInputValue = vi.fn() - - const { container } = render() - - // Create a very long file path - const longPath = - "/Users/test/project/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript" - - // Create a mock dataTransfer object with the long path - const dataTransfer = { - getData: vi.fn().mockReturnValue(longPath), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was called with the long path - expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd) - - // The mock implementation will convert it to @/very/long/path/... - expect(setInputValue).toHaveBeenCalledWith( - "@/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript ", - ) - }) - - it("should handle paths with special characters correctly", () => { - const setInputValue = vi.fn() - - const { container } = render() - - // Create paths with special characters - const specialPath1 = "/Users/test/project/file with spaces.js" - const specialPath2 = "/Users/test/project/file-with-dashes.js" - const specialPath3 = "/Users/test/project/file_with_underscores.js" - const specialPath4 = "/Users/test/project/file.with.dots.js" - - // Create a mock dataTransfer object with the special paths - const dataTransfer = { - getData: vi.fn().mockReturnValue(`${specialPath1}\n${specialPath2}\n${specialPath3}\n${specialPath4}`), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was called for each path - expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4) - expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd) - expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd) - expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd) - expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd) - - // Verify setInputValue was called with the correct value - expect(setInputValue).toHaveBeenCalledWith( - "@/file with spaces.js @/file-with-dashes.js @/file_with_underscores.js @/file.with.dots.js ", - ) - }) - - it("should handle paths outside the current working directory", () => { - const setInputValue = vi.fn() - - const { container } = render() - - // Create paths outside the current working directory - const outsidePath = "/Users/other/project/file.js" - - // Mock the convertToMentionPath function to return the original path for paths outside cwd - mockConvertToMentionPath.mockImplementationOnce((path, _cwd) => { - return path // Return original path for this test - }) - - // Create a mock dataTransfer object with the outside path - const dataTransfer = { - getData: vi.fn().mockReturnValue(outsidePath), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was called with the outside path - expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd) - - // Verify setInputValue was called with the original path - expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ") - }) - - it("should do nothing when dropped text is empty", () => { - const setInputValue = vi.fn() - - const { container } = render( - , - ) - - // Create a mock dataTransfer object with empty text - const dataTransfer = { - getData: vi.fn().mockReturnValue(""), - files: [], - } - - // Simulate drop event - fireEvent.drop(container.querySelector(".chat-text-area")!, { - dataTransfer, - preventDefault: vi.fn(), - }) - - // Verify convertToMentionPath was not called - expect(mockConvertToMentionPath).not.toHaveBeenCalled() - - // Verify setInputValue was not called - expect(setInputValue).not.toHaveBeenCalled() - }) - - describe("prompt history navigation", () => { - const mockClineMessages = [ - { type: "say", say: "user_feedback", text: "First prompt", ts: 1000 }, - { type: "say", say: "user_feedback", text: "Second prompt", ts: 2000 }, - { type: "say", say: "user_feedback", text: "Third prompt", ts: 3000 }, - ] - - beforeEach(() => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - clineMessages: mockClineMessages, - cwd: "/test/workspace", - }) - }) - - it("should navigate to previous prompt on arrow up when cursor is at beginning", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - // Ensure cursor is at the beginning - textarea.setSelectionRange(0, 0) - - // Simulate arrow up key press - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - - // Should set the newest conversation message (first in reversed array) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - }) - - it("should navigate through history with multiple arrow up presses", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // First arrow up - newest conversation message - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - - // Update input value to simulate the state change - setInputValue.mockClear() - - // Second arrow up - previous conversation message - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Second prompt") - }) - - it("should navigate forward with arrow down", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Go back in history first (index 0 -> "Third prompt", then index 1 -> "Second prompt") - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - setInputValue.mockClear() - - // Navigate forward (from index 1 back to index 0) - fireEvent.keyDown(textarea, { key: "ArrowDown" }) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - }) - - it("should preserve current input when starting navigation", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Navigate to history - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - - setInputValue.mockClear() - - // Navigate back to current input - fireEvent.keyDown(textarea, { key: "ArrowDown" }) - expect(setInputValue).toHaveBeenCalledWith("Current input") - }) - - it("should reset history navigation when user types", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Navigate to history - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - setInputValue.mockClear() - - // Type something - fireEvent.change(textarea, { target: { value: "New input", selectionStart: 9 } }) - - // Should reset history navigation - expect(setInputValue).toHaveBeenCalledWith("New input") - }) - - it("should reset history navigation when sending message", () => { - const onSend = vi.fn() - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Navigate to history first - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - setInputValue.mockClear() - - // Send message - fireEvent.keyDown(textarea, { key: "Enter" }) - - expect(onSend).toHaveBeenCalled() - }) - - it("should navigate history when cursor is at first line", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Clear any calls from initial render - setInputValue.mockClear() - - // With empty input, cursor is at first line by default - // Arrow up should navigate history - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - }) - - it("should filter history by current workspace", () => { - const mixedClineMessages = [ - { type: "say", say: "user_feedback", text: "Workspace 1 prompt", ts: 1000 }, - { type: "say", say: "user_feedback", text: "Other workspace prompt", ts: 2000 }, - { type: "say", say: "user_feedback", text: "Workspace 1 prompt 2", ts: 3000 }, - ] - - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - clineMessages: mixedClineMessages, - cwd: "/test/workspace", - }) - - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Should show conversation messages newest first (after reverse) - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Workspace 1 prompt 2") - - setInputValue.mockClear() - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Other workspace prompt") - }) - - it("should handle empty conversation history gracefully", () => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - clineMessages: [], - cwd: "/test/workspace", - }) - - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Should not crash or call setInputValue - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).not.toHaveBeenCalled() - }) - - it("should ignore empty or whitespace-only messages", () => { - const clineMessagesWithEmpty = [ - { type: "say", say: "user_feedback", text: "Valid prompt", ts: 1000 }, - { type: "say", say: "user_feedback", text: "", ts: 2000 }, - { type: "say", say: "user_feedback", text: " ", ts: 3000 }, - { type: "say", say: "user_feedback", text: "Another valid prompt", ts: 4000 }, - ] - - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - clineMessages: clineMessagesWithEmpty, - cwd: "/test/workspace", - }) - - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Should skip empty messages, newest first for conversation - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Another valid prompt") - - setInputValue.mockClear() - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Valid prompt") - }) - - it("should use task history (oldest first) when no conversation messages exist", () => { - const mockTaskHistory = [ - { task: "First task", workspace: "/test/workspace" }, - { task: "Second task", workspace: "/test/workspace" }, - { task: "Third task", workspace: "/test/workspace" }, - ] - - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: mockTaskHistory, - clineMessages: [], // No conversation messages - cwd: "/test/workspace", - }) - - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - - // Should show task history oldest first (chronological order) - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("First task") - - setInputValue.mockClear() - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Second task") - }) - - it("should reset navigation position when switching between history sources", () => { - const setInputValue = vi.fn() - const { rerender } = render( - , - ) - - // Start with task history - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [ - { task: "Task 1", workspace: "/test/workspace" }, - { task: "Task 2", workspace: "/test/workspace" }, - ], - clineMessages: [], - cwd: "/test/workspace", - }) - - rerender() - - const textarea = document.querySelector("textarea")! - - // Navigate in task history - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Task 1") - - // Switch to conversation messages - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - apiConfiguration: { - apiProvider: "anthropic", - }, - taskHistory: [], - clineMessages: [ - { type: "say", say: "user_feedback", text: "Message 1", ts: 1000 }, - { type: "say", say: "user_feedback", text: "Message 2", ts: 2000 }, - ], - cwd: "/test/workspace", - }) - - setInputValue.mockClear() - rerender() - - // Should start from beginning of conversation history (newest first) - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - expect(setInputValue).toHaveBeenCalledWith("Message 2") - }) - - it("should not navigate history with arrow up when cursor is not at beginning", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - // Set cursor to middle of text (not at beginning) - textarea.setSelectionRange(5, 5) - - // Clear any calls from initial render - setInputValue.mockClear() - - // Simulate arrow up key press - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - - // Should not navigate history, allowing default behavior (move cursor to start) - expect(setInputValue).not.toHaveBeenCalled() - }) - - it("should navigate history with arrow up when cursor is at beginning", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - // Set cursor to beginning of text - textarea.setSelectionRange(0, 0) - - // Clear any calls from initial render - setInputValue.mockClear() - - // Simulate arrow up key press - fireEvent.keyDown(textarea, { key: "ArrowUp" }) - - // Should navigate to history since cursor is at beginning - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - }) - - it("should navigate history with Command+Up when cursor is at beginning", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - // Set cursor to beginning of text - textarea.setSelectionRange(0, 0) - - // Clear any calls from initial render - setInputValue.mockClear() - - // Simulate Command+Up key press - fireEvent.keyDown(textarea, { key: "ArrowUp", metaKey: true }) - - // Should navigate to history since cursor is at beginning (same as regular Up) - expect(setInputValue).toHaveBeenCalledWith("Third prompt") - }) - - it("should not navigate history with Command+Up when cursor is not at beginning", () => { - const setInputValue = vi.fn() - const { container } = render( - , - ) - - const textarea = container.querySelector("textarea")! - // Set cursor to middle of text (not at beginning) - textarea.setSelectionRange(5, 5) - - // Clear any calls from initial render - setInputValue.mockClear() - - // Simulate Command+Up key press - fireEvent.keyDown(textarea, { key: "ArrowUp", metaKey: true }) - - // Should not navigate history, allowing default behavior (same as regular Up) - expect(setInputValue).not.toHaveBeenCalled() - }) - }) - }) - - describe("slash command highlighting", () => { - const mockCommands = [ - { name: "setup", source: "project", description: "Setup the project" }, - { name: "deploy", source: "global", description: "Deploy the application" }, - { name: "test-command", source: "project", description: "Test command with dash" }, - ] - - beforeEach(() => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - taskHistory: [], - cwd: "/test/workspace", - commands: mockCommands, - }) - }) - - it("should highlight valid slash commands", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // The highlighting is applied via innerHTML, so we need to check the content - // The valid command "/setup" should be highlighted - expect(highlightLayer.innerHTML).toContain('/setup') - }) - - it("should not highlight invalid slash commands", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // The invalid command "/invalid" should not be highlighted - expect(highlightLayer.innerHTML).not.toContain( - '/invalid', - ) - // But it should still contain the text without highlighting - expect(highlightLayer.innerHTML).toContain("/invalid") - }) - - it("should highlight only the command portion, not arguments", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // Only "/deploy" should be highlighted, not "to production" - expect(highlightLayer.innerHTML).toContain( - '/deploy', - ) - expect(highlightLayer.innerHTML).not.toContain( - '/deploy to production', - ) - }) - - it("should handle commands with dashes and underscores", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // The command with dash should be highlighted - expect(highlightLayer.innerHTML).toContain( - '/test-command', - ) - }) - - it("should be case-sensitive when matching commands", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // "/Setup" (capital S) should not be highlighted since the command is "setup" (lowercase) - expect(highlightLayer.innerHTML).not.toContain( - '/Setup', - ) - expect(highlightLayer.innerHTML).toContain("/Setup") - }) - - it("should highlight multiple valid commands in the same text", () => { - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // Both valid commands should be highlighted - expect(highlightLayer.innerHTML).toContain('/setup') - expect(highlightLayer.innerHTML).toContain( - '/deploy', - ) - }) - - it("should handle mixed valid and invalid commands", () => { - const { getByTestId } = render( - , - ) - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // Valid commands should be highlighted - expect(highlightLayer.innerHTML).toContain('/setup') - expect(highlightLayer.innerHTML).toContain( - '/deploy', - ) - - // Invalid command should not be highlighted - expect(highlightLayer.innerHTML).not.toContain( - '/invalid', - ) - expect(highlightLayer.innerHTML).toContain("/invalid") - }) - - it("should work when no commands are available", () => { - ;(useExtensionState as ReturnType).mockReturnValue({ - filePaths: [], - openedTabs: [], - taskHistory: [], - cwd: "/test/workspace", - commands: undefined, - }) - - const { getByTestId } = render() - - const highlightLayer = getByTestId("highlight-layer") - expect(highlightLayer).toBeInTheDocument() - - // No commands should be highlighted when commands array is undefined - expect(highlightLayer.innerHTML).not.toContain( - '/setup', - ) - expect(highlightLayer.innerHTML).toContain("/setup") - }) - }) - - describe("selectApiConfig", () => { - // Helper function to get the API config dropdown - const getApiConfigDropdown = () => { - return screen.getByTestId("dropdown-trigger") - } - it("should be enabled independently of sendingDisabled", () => { - render() - const apiConfigDropdown = getApiConfigDropdown() - expect(apiConfigDropdown).not.toHaveAttribute("disabled") - }) - it("should be disabled when selectApiConfigDisabled is true", () => { - render() - const apiConfigDropdown = getApiConfigDropdown() - expect(apiConfigDropdown).toHaveAttribute("disabled") - }) - }) - - describe("send button visibility", () => { - it("should show send button when there are images but no text", () => { - const { container } = render( - , - ) - - // Find the send button by looking for the button with SendHorizontal icon - const buttons = container.querySelectorAll("button") - const sendButton = Array.from(buttons).find( - (button) => button.querySelector(".lucide-send-horizontal") !== null, - ) - - expect(sendButton).toBeInTheDocument() - - // Check that the button is visible (has opacity-100 class when content exists) - expect(sendButton).toHaveClass("opacity-100") - expect(sendButton).toHaveClass("pointer-events-auto") - expect(sendButton).not.toHaveClass("opacity-0") - expect(sendButton).not.toHaveClass("pointer-events-none") - }) - - it("should hide send button when there is no text and no images", () => { - const { container } = render() - - // Find the send button by looking for the button with SendHorizontal icon - const buttons = container.querySelectorAll("button") - const sendButton = Array.from(buttons).find( - (button) => button.querySelector(".lucide-send-horizontal") !== null, - ) - - expect(sendButton).toBeInTheDocument() - - // Check that the button is hidden (has opacity-0 class when no content) - expect(sendButton).toHaveClass("opacity-0") - expect(sendButton).toHaveClass("pointer-events-none") - expect(sendButton).not.toHaveClass("opacity-100") - expect(sendButton).not.toHaveClass("pointer-events-auto") - }) - - it("should show send button when there is text but no images", () => { - const { container } = render() - - // Find the send button by looking for the button with SendHorizontal icon - const buttons = container.querySelectorAll("button") - const sendButton = Array.from(buttons).find( - (button) => button.querySelector(".lucide-send-horizontal") !== null, - ) - - expect(sendButton).toBeInTheDocument() - - // Check that the button is visible - expect(sendButton).toHaveClass("opacity-100") - expect(sendButton).toHaveClass("pointer-events-auto") - }) - - it("should show send button when there is both text and images", () => { - const { container } = render( - , - ) - - // Find the send button by looking for the button with SendHorizontal icon - const buttons = container.querySelectorAll("button") - const sendButton = Array.from(buttons).find( - (button) => button.querySelector(".lucide-send-horizontal") !== null, - ) - - expect(sendButton).toBeInTheDocument() - - // Check that the button is visible - expect(sendButton).toHaveClass("opacity-100") - expect(sendButton).toHaveClass("pointer-events-auto") - }) + it("should truncate long quoted text", () => { + const longQuotedText = "a".repeat(250) // 250 characters + + render( + , + ) + + // Check that the text is truncated + const displayedText = screen.getByText(/^a+\.\.\./) + expect(displayedText.textContent).toContain("...") + expect(displayedText.textContent!.length).toBeLessThan(250) }) }) diff --git a/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx b/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx new file mode 100644 index 0000000000..f2daa25064 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/Markdown.spec.tsx @@ -0,0 +1,204 @@ +import { render, fireEvent, waitFor } from "@testing-library/react" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import { Markdown } from "../Markdown" +import React from "react" + +// Mock vscode API +const mockPostMessage = vi.fn() +const vscodeApi = { + postMessage: mockPostMessage, +} + +// @ts-expect-error - Mocking global window API for testing +global.window.acquireVsCodeApi = vi.fn(() => vscodeApi) + +describe("Markdown Quote Selection", () => { + beforeEach(() => { + vi.clearAllMocks() + // Mock getSelection + global.window.getSelection = vi.fn( + () => + ({ + toString: vi.fn(() => ""), + removeAllRanges: vi.fn(), + getRangeAt: vi.fn(() => ({ + getBoundingClientRect: vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + })), + })), + rangeCount: 1, + }) as any, + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("should show quote button when text is selected", async () => { + const { container } = render( + , + ) + + // Mock text selection + const mockSelection = { + toString: vi.fn(() => "test content"), + removeAllRanges: vi.fn(), + getRangeAt: vi.fn(() => ({ + getBoundingClientRect: vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + })), + })), + rangeCount: 1, + } + global.window.getSelection = vi.fn(() => mockSelection as any) + + // Trigger selection change event + const selectionEvent = new Event("selectionchange", { bubbles: true }) + document.dispatchEvent(selectionEvent) + + // Wait for the quote button to appear + await waitFor(() => { + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') + expect(quoteButton).toBeTruthy() + }) + }) + + it("should send addQuoteToComposer message when quote button is clicked", async () => { + const { container } = render( + , + ) + + // Mock text selection + const selectedText = "test content" + const mockSelection = { + toString: vi.fn(() => selectedText), + removeAllRanges: vi.fn(), + getRangeAt: vi.fn(() => ({ + getBoundingClientRect: vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + })), + })), + rangeCount: 1, + } + global.window.getSelection = vi.fn(() => mockSelection as any) + + // Trigger selection change event + const selectionEvent = new Event("selectionchange", { bubbles: true }) + document.dispatchEvent(selectionEvent) + + // Wait for the quote button to appear + await waitFor(() => { + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') + expect(quoteButton).toBeTruthy() + }) + + // Click the quote button + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') as HTMLElement + fireEvent.click(quoteButton) + + // Verify the message was sent + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "addQuoteToComposer", + text: selectedText, + messageTs: 123456, + }) + }) + + it("should hide quote button when selection is cleared", async () => { + const { container } = render( + , + ) + + // Mock text selection + const mockSelection = { + toString: vi.fn(() => "test content"), + removeAllRanges: vi.fn(), + getRangeAt: vi.fn(() => ({ + getBoundingClientRect: vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + })), + })), + rangeCount: 1, + } + global.window.getSelection = vi.fn(() => mockSelection as any) + + // Trigger selection change event + const selectionEvent = new Event("selectionchange", { bubbles: true }) + document.dispatchEvent(selectionEvent) + + // Wait for the quote button to appear + await waitFor(() => { + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') + expect(quoteButton).toBeTruthy() + }) + + // Clear the selection + mockSelection.toString = vi.fn(() => "") + mockSelection.rangeCount = 0 + + // Trigger selection change event again + document.dispatchEvent(selectionEvent) + + // Wait for the quote button to disappear + await waitFor(() => { + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') + expect(quoteButton).toBeFalsy() + }) + }) + + it("should not show quote button when messageTs is not provided", async () => { + const { container } = render() + + // Mock text selection + const mockSelection = { + toString: vi.fn(() => "test content"), + removeAllRanges: vi.fn(), + getRangeAt: vi.fn(() => ({ + getBoundingClientRect: vi.fn(() => ({ + top: 100, + left: 50, + bottom: 120, + right: 200, + width: 150, + height: 20, + })), + })), + rangeCount: 1, + } + global.window.getSelection = vi.fn(() => mockSelection as any) + + // Trigger selection change event + const selectionEvent = new Event("selectionchange", { bubbles: true }) + document.dispatchEvent(selectionEvent) + + // Wait a bit and verify no quote button appears + await waitFor( + () => { + const quoteButton = container.querySelector('[aria-label="Quote selected text"]') + expect(quoteButton).toBeFalsy() + }, + { timeout: 100 }, + ) + }) +})