+
{markdown && !partial && isHovering && (
diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx
index 336e9f8357..93dd2f1f4f 100644
--- a/webview-ui/src/components/chat/ModeSelector.tsx
+++ b/webview-ui/src/components/chat/ModeSelector.tsx
@@ -1,5 +1,5 @@
import React from "react"
-import { ChevronUp, Check } from "lucide-react"
+import { ChevronUp, Check, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
@@ -11,6 +11,10 @@ import { Mode, getAllModes } from "@roo/modes"
import { ModeConfig, CustomModePrompts } from "@roo-code/types"
import { telemetryClient } from "@/utils/TelemetryClient"
import { TelemetryEventName } from "@roo-code/types"
+import { Fzf } from "fzf"
+
+// Minimum number of modes required to show search functionality
+const SEARCH_THRESHOLD = 6
interface ModeSelectorProps {
value: Mode
@@ -21,6 +25,7 @@ interface ModeSelectorProps {
modeShortcutText: string
customModes?: ModeConfig[]
customModePrompts?: CustomModePrompts
+ disableSearch?: boolean
}
export const ModeSelector = ({
@@ -32,13 +37,16 @@ export const ModeSelector = ({
modeShortcutText,
customModes,
customModePrompts,
+ disableSearch = false,
}: ModeSelectorProps) => {
const [open, setOpen] = React.useState(false)
+ const [searchValue, setSearchValue] = React.useState("")
+ const searchInputRef = React.useRef
(null)
const portalContainer = useRooPortal("roo-portal")
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
const { t } = useAppTranslation()
- const trackModeSelectorOpened = () => {
+ const trackModeSelectorOpened = React.useCallback(() => {
// Track telemetry every time the mode selector is opened
telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED)
@@ -47,7 +55,7 @@ export const ModeSelector = ({
setHasOpenedModeSelector(true)
vscode.postMessage({ type: "hasOpenedModeSelector", bool: true })
}
- }
+ }, [hasOpenedModeSelector, setHasOpenedModeSelector])
// Get all modes including custom modes and merge custom prompt descriptions
const modes = React.useMemo(() => {
@@ -61,6 +69,96 @@ export const ModeSelector = ({
// Find the selected mode
const selectedMode = React.useMemo(() => modes.find((mode) => mode.slug === value), [modes, value])
+ // Memoize searchable items for fuzzy search with separate name and description search
+ const nameSearchItems = React.useMemo(() => {
+ return modes.map((mode) => ({
+ original: mode,
+ searchStr: [mode.name, mode.slug].filter(Boolean).join(" "),
+ }))
+ }, [modes])
+
+ const descriptionSearchItems = React.useMemo(() => {
+ return modes.map((mode) => ({
+ original: mode,
+ searchStr: mode.description || "",
+ }))
+ }, [modes])
+
+ // Create memoized Fzf instances for name and description searches
+ const nameFzfInstance = React.useMemo(() => {
+ return new Fzf(nameSearchItems, {
+ selector: (item) => item.searchStr,
+ })
+ }, [nameSearchItems])
+
+ const descriptionFzfInstance = React.useMemo(() => {
+ return new Fzf(descriptionSearchItems, {
+ selector: (item) => item.searchStr,
+ })
+ }, [descriptionSearchItems])
+
+ // Filter modes based on search value using fuzzy search with priority
+ const filteredModes = React.useMemo(() => {
+ if (!searchValue) return modes
+
+ // First search in names/slugs
+ const nameMatches = nameFzfInstance.find(searchValue)
+ const nameMatchedModes = new Set(nameMatches.map((result) => result.item.original.slug))
+
+ // Then search in descriptions
+ const descriptionMatches = descriptionFzfInstance.find(searchValue)
+
+ // Combine results: name matches first, then description matches
+ const combinedResults = [
+ ...nameMatches.map((result) => result.item.original),
+ ...descriptionMatches
+ .filter((result) => !nameMatchedModes.has(result.item.original.slug))
+ .map((result) => result.item.original),
+ ]
+
+ return combinedResults
+ }, [modes, searchValue, nameFzfInstance, descriptionFzfInstance])
+
+ const onClearSearch = React.useCallback(() => {
+ setSearchValue("")
+ searchInputRef.current?.focus()
+ }, [])
+
+ const handleSelect = React.useCallback(
+ (modeSlug: string) => {
+ onChange(modeSlug as Mode)
+ setOpen(false)
+ // Clear search after selection
+ setSearchValue("")
+ },
+ [onChange],
+ )
+
+ const onOpenChange = React.useCallback(
+ (isOpen: boolean) => {
+ if (isOpen) trackModeSelectorOpened()
+ setOpen(isOpen)
+ // Clear search when closing
+ if (!isOpen) {
+ setSearchValue("")
+ }
+ },
+ [trackModeSelectorOpened],
+ )
+
+ // Auto-focus search input when popover opens
+ React.useEffect(() => {
+ if (open && searchInputRef.current) {
+ searchInputRef.current.focus()
+ }
+ }, [open])
+
+ // Determine if search should be shown
+ const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD
+
+ // Combine instruction text for tooltip
+ const instructionText = `${t("chat:modeSelector.description")} ${modeShortcutText}`
+
const trigger = (
{
- if (isOpen) trackModeSelectorOpened()
- setOpen(isOpen)
- }}
- data-testid="mode-selector-root">
+
{title ? {trigger} : trigger}
-
-
-
{t("chat:modeSelector.title")}
-
- {
- window.postMessage(
- {
- type: "action",
- action: "marketplaceButtonClicked",
- values: { marketplaceTab: "mode" },
- },
- "*",
- )
-
- setOpen(false)
- }}
- />
- {
- vscode.postMessage({
- type: "switchTab",
- tab: "modes",
- })
- setOpen(false)
- }}
- />
-
+ {/* Show search bar only when there are more than SEARCH_THRESHOLD items, otherwise show info blurb */}
+ {showSearch ? (
+
+
setSearchValue(e.target.value)}
+ placeholder={t("chat:modeSelector.searchPlaceholder")}
+ className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
+ data-testid="mode-search-input"
+ />
+ {searchValue.length > 0 && (
+
+
+
+ )}
-
- {t("chat:modeSelector.description")}
-
- {modeShortcutText}
-
-
+ ) : (
+
+ )}
{/* Mode List */}
-
- {modes.map((mode) => (
-
+ {filteredModes.length === 0 && searchValue ? (
+
+ {t("chat:modeSelector.noResults")}
+
+ ) : (
+
+ {filteredModes.map((mode) => (
+
handleSelect(mode.slug)}
+ className={cn(
+ "px-3 py-1.5 text-sm cursor-pointer flex items-center",
+ "hover:bg-vscode-list-hoverBackground",
+ mode.slug === value
+ ? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
+ : "",
+ )}
+ data-testid="mode-selector-item">
+
+
{mode.name}
+ {mode.description && (
+
+ {mode.description}
+
+ )}
+
+ {mode.slug === value &&
}
+
+ ))}
+
+ )}
+
+
+ {/* Bottom bar with buttons on left and title on right */}
+
+
+
{
- onChange(mode.slug as Mode)
+ window.postMessage(
+ {
+ type: "action",
+ action: "marketplaceButtonClicked",
+ values: { marketplaceTab: "mode" },
+ },
+ "*",
+ )
setOpen(false)
}}
- data-testid="mode-selector-item">
-
-
{mode.name}
- {mode.description && (
-
- {mode.description}
-
- )}
-
- {mode.slug === value ? (
-
- ) : (
-
- )}
-
- ))}
+ />
+
{
+ vscode.postMessage({
+ type: "switchTab",
+ tab: "modes",
+ })
+ setOpen(false)
+ }}
+ />
+
+
+ {/* Info icon and title on the right - only show info icon when search bar is visible */}
+
+ {showSearch && (
+
+
+
+ )}
+
+ {t("chat:modeSelector.title")}
+
+
diff --git a/webview-ui/src/components/chat/QueuedMessages.tsx b/webview-ui/src/components/chat/QueuedMessages.tsx
new file mode 100644
index 0000000000..cd3ee6d896
--- /dev/null
+++ b/webview-ui/src/components/chat/QueuedMessages.tsx
@@ -0,0 +1,112 @@
+import React, { useState } from "react"
+import { useTranslation } from "react-i18next"
+import Thumbnails from "../common/Thumbnails"
+import { QueuedMessage } from "@roo-code/types"
+import { Mention } from "./Mention"
+import { Button } from "@src/components/ui"
+
+interface QueuedMessagesProps {
+ queue: QueuedMessage[]
+ onRemove: (index: number) => void
+ onUpdate: (index: number, newText: string) => void
+}
+
+const QueuedMessages: React.FC
= ({ queue, onRemove, onUpdate }) => {
+ const { t } = useTranslation("chat")
+ const [editingStates, setEditingStates] = useState>({})
+
+ if (queue.length === 0) {
+ return null
+ }
+
+ const getEditState = (messageId: string, currentText: string) => {
+ return editingStates[messageId] || { isEditing: false, value: currentText }
+ }
+
+ const setEditState = (messageId: string, isEditing: boolean, value?: string) => {
+ setEditingStates((prev) => ({
+ ...prev,
+ [messageId]: { isEditing, value: value ?? prev[messageId]?.value ?? "" },
+ }))
+ }
+
+ const handleSaveEdit = (index: number, messageId: string, newValue: string) => {
+ onUpdate(index, newValue)
+ setEditState(messageId, false)
+ }
+
+ return (
+
+
{t("queuedMessages.title")}
+
+ {queue.map((message, index) => {
+ const editState = getEditState(message.id, message.text)
+
+ return (
+
+
+
+ {editState.isEditing ? (
+
+
+
+
+
+ {message.images && message.images.length > 0 && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
+export default QueuedMessages
diff --git a/webview-ui/src/components/chat/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx
new file mode 100644
index 0000000000..b062341bf1
--- /dev/null
+++ b/webview-ui/src/components/chat/SlashCommandItem.tsx
@@ -0,0 +1,72 @@
+import React from "react"
+import { Edit, Trash2 } from "lucide-react"
+
+import type { Command } from "@roo/ExtensionMessage"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { Button, StandardTooltip } from "@/components/ui"
+import { vscode } from "@/utils/vscode"
+
+interface SlashCommandItemProps {
+ command: Command
+ onDelete: (command: Command) => void
+ onClick?: (command: Command) => void
+}
+
+export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => {
+ const { t } = useAppTranslation()
+
+ const handleEdit = () => {
+ if (command.filePath) {
+ vscode.postMessage({
+ type: "openFile",
+ text: command.filePath,
+ })
+ } else {
+ // Fallback: request to open command file by name and source
+ vscode.postMessage({
+ type: "openCommandFile",
+ text: command.name,
+ values: { source: command.source },
+ })
+ }
+ }
+
+ const handleDelete = () => {
+ onDelete(command)
+ }
+
+ return (
+
+ {/* Command name - clickable */}
+
onClick?.(command)}>
+ {command.name}
+
+
+ {/* Action buttons */}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/chat/SlashCommandsList.tsx b/webview-ui/src/components/chat/SlashCommandsList.tsx
new file mode 100644
index 0000000000..a80f447722
--- /dev/null
+++ b/webview-ui/src/components/chat/SlashCommandsList.tsx
@@ -0,0 +1,203 @@
+import React, { useState } from "react"
+import { Plus, Globe, Folder } from "lucide-react"
+
+import type { Command } from "@roo/ExtensionMessage"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { useExtensionState } from "@/context/ExtensionStateContext"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+} from "@/components/ui"
+import { vscode } from "@/utils/vscode"
+
+import { SlashCommandItem } from "./SlashCommandItem"
+
+interface SlashCommandsListProps {
+ commands: Command[]
+ onRefresh: () => void
+}
+
+export const SlashCommandsList: React.FC = ({ commands, onRefresh }) => {
+ const { t } = useAppTranslation()
+ const { cwd } = useExtensionState()
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [commandToDelete, setCommandToDelete] = useState(null)
+ const [globalNewName, setGlobalNewName] = useState("")
+ const [workspaceNewName, setWorkspaceNewName] = useState("")
+
+ // Check if we're in a workspace/project
+ const hasWorkspace = Boolean(cwd)
+
+ const handleDeleteClick = (command: Command) => {
+ setCommandToDelete(command)
+ setDeleteDialogOpen(true)
+ }
+
+ const handleDeleteConfirm = () => {
+ if (commandToDelete) {
+ vscode.postMessage({
+ type: "deleteCommand",
+ text: commandToDelete.name,
+ values: { source: commandToDelete.source },
+ })
+ setDeleteDialogOpen(false)
+ setCommandToDelete(null)
+ // Refresh the commands list after deletion
+ setTimeout(onRefresh, 100)
+ }
+ }
+
+ const handleDeleteCancel = () => {
+ setDeleteDialogOpen(false)
+ setCommandToDelete(null)
+ }
+
+ const handleCreateCommand = (source: "global" | "project", name: string) => {
+ if (!name.trim()) return
+
+ // Append .md if not already present
+ const fileName = name.trim().endsWith(".md") ? name.trim() : `${name.trim()}.md`
+
+ vscode.postMessage({
+ type: "createCommand",
+ text: fileName,
+ values: { source },
+ })
+
+ // Clear the input and refresh
+ if (source === "global") {
+ setGlobalNewName("")
+ } else {
+ setWorkspaceNewName("")
+ }
+ setTimeout(onRefresh, 500)
+ }
+
+ const handleCommandClick = (command: Command) => {
+ // Insert the command into the textarea
+ vscode.postMessage({
+ type: "insertTextIntoTextarea",
+ text: `/${command.name}`,
+ })
+ }
+
+ // Group commands by source
+ const globalCommands = commands.filter((cmd) => cmd.source === "global")
+ const projectCommands = commands.filter((cmd) => cmd.source === "project")
+
+ return (
+ <>
+ {/* Commands list */}
+
+
+ {/* Global Commands Section */}
+
+
+ {t("chat:slashCommands.globalCommands")}
+
+ {globalCommands.map((command) => (
+
+ ))}
+ {/* New global command input */}
+
+
setGlobalNewName(e.target.value)}
+ placeholder={t("chat:slashCommands.newGlobalCommandPlaceholder")}
+ className="flex-1 bg-transparent text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border-none outline-none focus:outline-0 text-sm"
+ tabIndex={-1}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleCreateCommand("global", globalNewName)
+ }
+ }}
+ />
+
+
+
+ {/* Workspace Commands Section - Only show if in a workspace */}
+ {hasWorkspace && (
+ <>
+
+
+ {t("chat:slashCommands.workspaceCommands")}
+
+ {projectCommands.map((command) => (
+
+ ))}
+ {/* New workspace command input */}
+
+
setWorkspaceNewName(e.target.value)}
+ placeholder={t("chat:slashCommands.newWorkspaceCommandPlaceholder")}
+ className="flex-1 bg-transparent text-vscode-input-foreground placeholder-vscode-input-placeholderForeground border-none outline-none focus:outline-0 text-sm"
+ tabIndex={-1}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleCreateCommand("project", workspaceNewName)
+ }
+ }}
+ />
+
+
+ >
+ )}
+
+
+
+
+
+
+ {t("chat:slashCommands.deleteDialog.title")}
+
+ {t("chat:slashCommands.deleteDialog.description", { name: commandToDelete?.name })}
+
+
+
+
+ {t("chat:slashCommands.deleteDialog.cancel")}
+
+
+ {t("chat:slashCommands.deleteDialog.confirm")}
+
+
+
+
+ >
+ )
+}
diff --git a/webview-ui/src/components/chat/SlashCommandsPopover.tsx b/webview-ui/src/components/chat/SlashCommandsPopover.tsx
new file mode 100644
index 0000000000..fc17760fc1
--- /dev/null
+++ b/webview-ui/src/components/chat/SlashCommandsPopover.tsx
@@ -0,0 +1,82 @@
+import React, { useEffect, useState } from "react"
+import { Zap } from "lucide-react"
+
+import { useAppTranslation } from "@/i18n/TranslationContext"
+import { useExtensionState } from "@/context/ExtensionStateContext"
+import { Button, Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
+import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
+import { cn } from "@/lib/utils"
+import { vscode } from "@/utils/vscode"
+
+import { SlashCommandsList } from "./SlashCommandsList"
+
+interface SlashCommandsPopoverProps {
+ className?: string
+}
+
+export const SlashCommandsPopover: React.FC = ({ className }) => {
+ const { t } = useAppTranslation()
+ const { commands } = useExtensionState()
+ const [isOpen, setIsOpen] = useState(false)
+ const portalContainer = useRooPortal("roo-portal")
+
+ // Request commands when popover opens
+ useEffect(() => {
+ if (isOpen && (!commands || commands.length === 0)) {
+ handleRefresh()
+ }
+ }, [isOpen, commands])
+
+ const handleRefresh = () => {
+ vscode.postMessage({ type: "requestCommands" })
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open)
+ if (open) {
+ // Always refresh when opening to get latest commands
+ handleRefresh()
+ }
+ }
+
+ const trigger = (
+
+
+
+ )
+
+ return (
+
+ {trigger}
+
+
+
+ {/* Header section */}
+
+
+ {t("chat:slashCommands.description")}
+
+
+
+ {/* Commands list */}
+
+
+
+
+ )
+}
diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx
new file mode 100644
index 0000000000..934d14cc7b
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx
@@ -0,0 +1,418 @@
+import React from "react"
+import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
+import { describe, test, expect, vi, beforeEach } from "vitest"
+import { ApiConfigSelector } from "../ApiConfigSelector"
+import { vscode } from "@/utils/vscode"
+
+// Mock the dependencies
+vi.mock("@/utils/vscode", () => ({
+ vscode: {
+ postMessage: vi.fn(),
+ },
+}))
+
+vi.mock("@/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}))
+
+vi.mock("@/components/ui/hooks/useRooPortal", () => ({
+ useRooPortal: () => document.body,
+}))
+
+// Mock Popover components to be testable
+vi.mock("@/components/ui", () => ({
+ Popover: ({ children, open }: any) => (
+
+ {children}
+
+ ),
+ PopoverTrigger: ({ children, disabled, ...props }: any) => (
+
+ ),
+ PopoverContent: ({ children }: any) => {children}
,
+ StandardTooltip: ({ children }: any) => <>{children}>,
+ Button: ({ children, onClick, ...props }: any) => (
+
+ ),
+}))
+
+describe("ApiConfigSelector", () => {
+ const mockOnChange = vi.fn()
+ const mockTogglePinnedApiConfig = vi.fn()
+
+ const defaultProps = {
+ value: "config1",
+ displayName: "Config 1",
+ onChange: mockOnChange,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ ],
+ pinnedApiConfigs: { config1: true },
+ togglePinnedApiConfig: mockTogglePinnedApiConfig,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test("renders correctly with default props", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ expect(trigger).toBeInTheDocument()
+ expect(trigger).toHaveTextContent("Config 1")
+ })
+
+ test("renders with ChevronUp icon", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ // Check for the icon by looking for the codicon span element
+ const icon = trigger.querySelector(".codicon-chevron-up")
+ expect(icon).toBeInTheDocument()
+ })
+
+ test("handles disabled state correctly", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ expect(trigger).toBeDisabled()
+ })
+
+ test("renders with custom title tooltip", () => {
+ const customTitle = "Custom tooltip text"
+ render()
+
+ // The component should render with the tooltip wrapper
+ const trigger = screen.getByTestId("dropdown-trigger")
+ expect(trigger).toBeInTheDocument()
+ })
+
+ test("applies custom trigger className", () => {
+ const customClass = "custom-trigger-class"
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ expect(trigger.className).toContain(customClass)
+ })
+
+ test("opens popover when trigger is clicked", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Check if popover content is rendered
+ const popoverContent = screen.getByTestId("popover-content")
+ expect(popoverContent).toBeInTheDocument()
+ })
+
+ test("renders search input when popover is open and more than 6 configs", () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+ expect(searchInput).toBeInTheDocument()
+ })
+
+ test("renders info blurb instead of search when 6 or fewer configs", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Should not have search input
+ expect(screen.queryByPlaceholderText("common:ui.search_placeholder")).not.toBeInTheDocument()
+ // Should have info blurb
+ expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument()
+ })
+
+ test("filters configs based on search input", async () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+ fireEvent.change(searchInput, { target: { value: "Config 2" } })
+
+ // Wait for the filtering to take effect
+ await waitFor(() => {
+ // Config 2 should be visible
+ expect(screen.getByText("Config 2")).toBeInTheDocument()
+ // Config 3 should not be visible (assuming exact match filtering)
+ expect(screen.queryByText("Config 3")).not.toBeInTheDocument()
+ })
+ })
+
+ test("shows no results message when search has no matches", async () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder")
+ fireEvent.change(searchInput, { target: { value: "NonExistentConfig" } })
+
+ await waitFor(() => {
+ expect(screen.getByText("common:ui.no_results")).toBeInTheDocument()
+ })
+ })
+
+ test("clears search when X button is clicked", async () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") as HTMLInputElement
+ fireEvent.change(searchInput, { target: { value: "test" } })
+
+ expect(searchInput.value).toBe("test")
+
+ // Find and click the X button
+ const clearButton = screen.getByTestId("popover-content").querySelector(".cursor-pointer")
+ if (clearButton) {
+ fireEvent.click(clearButton)
+ }
+
+ await waitFor(() => {
+ expect(searchInput.value).toBe("")
+ })
+ })
+
+ test("calls onChange when a config is selected", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const config2 = screen.getByText("Config 2")
+ fireEvent.click(config2)
+
+ expect(mockOnChange).toHaveBeenCalledWith("config2")
+ })
+
+ test("shows check mark for selected config", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // The selected config (config1) should have a check mark
+ // Use getAllByText since there might be multiple elements with "Config 1"
+ const config1Elements = screen.getAllByText("Config 1")
+ // Find the one that's in the dropdown content (not the trigger)
+ const configInDropdown = config1Elements.find((el) => el.closest('[data-testid="popover-content"]'))
+ const selectedConfigRow = configInDropdown?.closest("div")
+ const checkIcon = selectedConfigRow?.querySelector(".codicon-check")
+ expect(checkIcon).toBeInTheDocument()
+ })
+
+ test("separates pinned and unpinned configs", () => {
+ const props = {
+ ...defaultProps,
+ pinnedApiConfigs: { config1: true, config3: true },
+ }
+
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const content = screen.getByTestId("popover-content")
+ const configTexts = content.querySelectorAll(".truncate")
+
+ // Pinned configs should appear first
+ expect(configTexts[0]).toHaveTextContent("Config 1")
+ expect(configTexts[1]).toHaveTextContent("Config 3")
+ // Unpinned config should appear after separator
+ expect(configTexts[2]).toHaveTextContent("Config 2")
+ })
+
+ test("toggles pin status when pin button is clicked", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Find the pin button for Config 2 (unpinned)
+ const config2Row = screen.getByText("Config 2").closest("div")
+ const pinButton = config2Row?.querySelector("button")
+
+ if (pinButton) {
+ fireEvent.click(pinButton)
+ }
+
+ expect(mockTogglePinnedApiConfig).toHaveBeenCalledWith("config2")
+ expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
+ type: "toggleApiConfigPin",
+ text: "config2",
+ })
+ })
+
+ test("opens settings when edit button is clicked", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Find the settings button by its icon class within the popover content
+ const popoverContent = screen.getByTestId("popover-content")
+ const settingsButton = popoverContent.querySelector('[aria-label="chat:edit"]') as HTMLElement
+ expect(settingsButton).toBeInTheDocument()
+ fireEvent.click(settingsButton)
+
+ expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
+ type: "switchTab",
+ tab: "settings",
+ })
+ })
+
+ test("renders bottom bar with title and info icon when more than 6 configs", () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Check for the title
+ expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+
+ // Check for the info icon
+ const infoIcon = screen.getByTestId("popover-content").querySelector(".codicon-info")
+ expect(infoIcon).toBeInTheDocument()
+ })
+
+ test("renders bottom bar with title but no info icon when 6 or fewer configs", () => {
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Check for the title
+ expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+
+ // Check that info icon is not present
+ const infoIcon = screen.getByTestId("popover-content").querySelector(".codicon-info")
+ expect(infoIcon).not.toBeInTheDocument()
+ })
+
+ test("handles empty config list gracefully", () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [],
+ }
+
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ // Should render info blurb instead of search for empty list
+ expect(screen.queryByPlaceholderText("common:ui.search_placeholder")).not.toBeInTheDocument()
+ expect(screen.getByText("prompts:apiConfiguration.select")).toBeInTheDocument()
+ expect(screen.getByText("prompts:apiConfiguration.title")).toBeInTheDocument()
+ })
+
+ test("maintains search value when pinning/unpinning", async () => {
+ const props = {
+ ...defaultProps,
+ listApiConfigMeta: [
+ { id: "config1", name: "Config 1" },
+ { id: "config2", name: "Config 2" },
+ { id: "config3", name: "Config 3" },
+ { id: "config4", name: "Config 4" },
+ { id: "config5", name: "Config 5" },
+ { id: "config6", name: "Config 6" },
+ { id: "config7", name: "Config 7" },
+ ],
+ }
+ render()
+
+ const trigger = screen.getByTestId("dropdown-trigger")
+ fireEvent.click(trigger)
+
+ const searchInput = screen.getByPlaceholderText("common:ui.search_placeholder") as HTMLInputElement
+ fireEvent.change(searchInput, { target: { value: "Config" } })
+
+ // Pin a config
+ const config2Row = screen.getByText("Config 2").closest("div")
+ const pinButton = config2Row?.querySelector("button")
+ if (pinButton) {
+ fireEvent.click(pinButton)
+ }
+
+ // Search value should be maintained
+ expect(searchInput.value).toBe("Config")
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
index f53bab76a4..8f3a33c77d 100644
--- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
@@ -77,7 +77,7 @@ describe("ChatTextArea", () => {
})
describe("enhance prompt button", () => {
- it("should be disabled when sendingDisabled is true", () => {
+ it("should be enabled even when sendingDisabled is true (for message queueing)", () => {
;(useExtensionState as ReturnType).mockReturnValue({
filePaths: [],
openedTabs: [],
@@ -86,7 +86,7 @@ describe("ChatTextArea", () => {
})
render()
const enhanceButton = getEnhancePromptButton()
- expect(enhanceButton).toHaveClass("cursor-not-allowed")
+ expect(enhanceButton).toHaveClass("cursor-pointer")
})
})
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx
new file mode 100644
index 0000000000..4b23166bce
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx
@@ -0,0 +1,288 @@
+// npx vitest run src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx
+
+import React from "react"
+import { render, fireEvent } 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"
+
+// Mock vscode API
+vi.mock("@src/utils/vscode", () => ({
+ vscode: {
+ postMessage: vi.fn(),
+ },
+}))
+
+// Mock use-sound hook
+vi.mock("use-sound", () => ({
+ default: vi.fn().mockImplementation(() => {
+ return [vi.fn()]
+ }),
+}))
+
+// Mock components
+vi.mock("../BrowserSessionRow", () => ({
+ default: () => null,
+}))
+
+vi.mock("../ChatRow", () => ({
+ default: () => null,
+}))
+
+vi.mock("../AutoApproveMenu", () => ({
+ default: () => null,
+}))
+
+vi.mock("../../common/VersionIndicator", () => ({
+ default: () => null,
+}))
+
+vi.mock("@src/components/modals/Announcement", () => ({
+ default: () => null,
+}))
+
+vi.mock("@src/components/welcome/RooCloudCTA", () => ({
+ default: () => null,
+}))
+
+vi.mock("@src/components/welcome/RooTips", () => ({
+ default: () => null,
+}))
+
+vi.mock("@src/components/welcome/RooHero", () => ({
+ default: () => null,
+}))
+
+vi.mock("../common/TelemetryBanner", () => ({
+ default: () => null,
+}))
+
+// Mock i18n
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ initReactI18next: {
+ type: "3rdParty",
+ init: () => {},
+ },
+ Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}>,
+}))
+
+vi.mock("../ChatTextArea", () => {
+ return {
+ default: React.forwardRef(function MockChatTextArea(
+ _props: any,
+ ref: React.ForwardedRef<{ focus: () => void }>,
+ ) {
+ React.useImperativeHandle(ref, () => ({
+ focus: vi.fn(),
+ }))
+ return
+ }),
+ }
+})
+
+// Mock VSCode components
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+ VSCodeButton: ({ children, onClick }: any) => ,
+ VSCodeLink: ({ children, href }: any) => {children},
+}))
+
+// Mock window.postMessage to trigger state hydration
+const mockPostMessage = (state: any) => {
+ window.postMessage(
+ {
+ type: "state",
+ state: {
+ version: "1.0.0",
+ clineMessages: [],
+ taskHistory: [],
+ shouldShowAnnouncement: false,
+ allowedCommands: [],
+ alwaysAllowExecute: false,
+ cloudIsAuthenticated: false,
+ telemetrySetting: "enabled",
+ mode: "code",
+ customModes: [],
+ ...state,
+ },
+ },
+ "*",
+ )
+}
+
+const defaultProps: ChatViewProps = {
+ isHidden: false,
+ showAnnouncement: false,
+ hideAnnouncement: () => {},
+}
+
+const queryClient = new QueryClient()
+
+const renderChatView = (props: Partial = {}) => {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("uses event.key instead of event.code for keyboard shortcuts", async () => {
+ renderChatView()
+
+ // Hydrate state
+ mockPostMessage({
+ mode: "code",
+ customModes: [],
+ })
+
+ // Wait for component to be ready
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Clear any initial calls
+ vi.clearAllMocks()
+
+ // Test 1: Period key should trigger mode switch
+ fireEvent.keyDown(window, {
+ key: ".",
+ code: "Period",
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: false,
+ })
+
+ // Wait for event to be processed
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ // Check if mode switch was triggered
+ const callsAfterPeriod = (vscode.postMessage as any).mock.calls
+ const modeSwitchAfterPeriod = callsAfterPeriod.some((call: any[]) => call[0]?.type === "mode")
+ expect(modeSwitchAfterPeriod).toBe(true)
+
+ // Clear mocks
+ vi.clearAllMocks()
+
+ // Test 2: V key on physical Period key (Dvorak) should NOT trigger mode switch
+ fireEvent.keyDown(window, {
+ key: "v",
+ code: "Period", // Physical key is Period, but produces 'v'
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: false,
+ })
+
+ // Wait for event to be processed
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ // Check that NO mode switch was triggered
+ const callsAfterV = (vscode.postMessage as any).mock.calls
+ const modeSwitchAfterV = callsAfterV.some((call: any[]) => call[0]?.type === "mode")
+ expect(modeSwitchAfterV).toBe(false)
+ })
+
+ it("prevents default behavior when mode switch is triggered", () => {
+ renderChatView()
+
+ // Hydrate state
+ mockPostMessage({
+ mode: "code",
+ customModes: [],
+ })
+
+ // Create a keyboard event with preventDefault spy
+ const event = new KeyboardEvent("keydown", {
+ key: ".",
+ code: "Period",
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: false,
+ bubbles: true,
+ cancelable: true,
+ })
+
+ const preventDefaultSpy = vi.spyOn(event, "preventDefault")
+
+ // Dispatch the event
+ window.dispatchEvent(event)
+
+ // Verify preventDefault was called
+ expect(preventDefaultSpy).toHaveBeenCalled()
+ })
+
+ it("works with Cmd key on Mac", async () => {
+ renderChatView()
+
+ // Hydrate state
+ mockPostMessage({
+ mode: "code",
+ customModes: [],
+ })
+
+ // Wait for component to be ready
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Clear any initial calls
+ vi.clearAllMocks()
+
+ // Test with Cmd key (Mac)
+ fireEvent.keyDown(window, {
+ key: ".",
+ code: "Period",
+ ctrlKey: false,
+ metaKey: true, // Cmd key on Mac
+ shiftKey: false,
+ })
+
+ // Wait for event to be processed
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ // Check if mode switch was triggered
+ const calls = (vscode.postMessage as any).mock.calls
+ const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
+ expect(modeSwitch).toBe(true)
+ })
+
+ it("handles Shift modifier for previous mode", async () => {
+ renderChatView()
+
+ // Hydrate state
+ mockPostMessage({
+ mode: "code",
+ customModes: [],
+ })
+
+ // Wait for component to be ready
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Clear any initial calls
+ vi.clearAllMocks()
+
+ // Test with Shift modifier
+ fireEvent.keyDown(window, {
+ key: ".",
+ code: "Period",
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: true, // Should go to previous mode
+ })
+
+ // Wait for event to be processed
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ // Check if mode switch was triggered
+ const calls = (vscode.postMessage as any).mock.calls
+ const modeSwitch = calls.some((call: any[]) => call[0]?.type === "mode")
+ expect(modeSwitch).toBe(true)
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
index 7545dae140..19538ef932 100644
--- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx
@@ -72,7 +72,7 @@ const mockVersionIndicator = vi.mocked(
(await import("../../common/VersionIndicator")).default,
)
-vi.mock("@src/components/modals/Announcement", () => ({
+vi.mock("../Announcement", () => ({
default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const React = require("react")
@@ -98,6 +98,33 @@ vi.mock("@src/components/welcome/RooCloudCTA", () => ({
},
}))
+// Mock QueuedMessages component
+vi.mock("../QueuedMessages", () => ({
+ default: function MockQueuedMessages({
+ messages = [],
+ onRemoveMessage,
+ }: {
+ messages?: Array<{ id: string; text: string; images?: string[] }>
+ onRemoveMessage?: (id: string) => void
+ }) {
+ if (!messages || messages.length === 0) {
+ return null
+ }
+ return (
+
+ {messages.map((msg) => (
+
+ {msg.text}
+
+
+ ))}
+
+ )
+ },
+}))
+
// Mock RooTips component
vi.mock("@src/components/welcome/RooTips", () => ({
default: function MockRooTips() {
@@ -166,7 +193,15 @@ vi.mock("../ChatTextArea", () => {
return (
- props.onSend(e.target.value)} />
+ {
+ // With message queueing, onSend is always called (it handles queueing internally)
+ props.onSend(e.target.value)
+ }}
+ data-sending-disabled={props.sendingDisabled}
+ />
)
}),
@@ -313,15 +348,14 @@ describe("ChatView - Auto Approval Tests", () => {
},
{
type: "ask",
- ask: testCase.ask,
+ ask: testCase.ask as any,
ts: Date.now(),
text: testCase.text,
- partial: false,
},
],
})
- // Verify no auto-approval message was sent
+ // Should not auto-approve when autoApprovalEnabled is false
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
@@ -346,7 +380,10 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send the browser action ask message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add browser action
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
@@ -362,12 +399,11 @@ describe("ChatView - Auto Approval Tests", () => {
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
- partial: false,
},
],
})
- // Wait for the auto-approval message
+ // Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
@@ -393,7 +429,10 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send the read-only tool ask message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add read-only tool request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
@@ -409,12 +448,11 @@ describe("ChatView - Auto Approval Tests", () => {
ask: "tool",
ts: Date.now(),
text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
- partial: false,
},
],
})
- // Wait for the auto-approval message
+ // Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
@@ -431,7 +469,7 @@ describe("ChatView - Auto Approval Tests", () => {
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
- writeDelayMs: 0,
+ writeDelayMs: 100, // Short delay for testing
clineMessages: [
{
type: "say",
@@ -442,11 +480,14 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send the write tool ask message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add write tool request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
- writeDelayMs: 0,
+ writeDelayMs: 100, // Short delay for testing
clineMessages: [
{
type: "say",
@@ -464,13 +505,16 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Wait for the auto-approval message
- await waitFor(() => {
- expect(vscode.postMessage).toHaveBeenCalledWith({
- type: "askResponse",
- askResponse: "yesButtonClicked",
- })
- })
+ // Wait for auto-approval to happen (with delay for write tools)
+ await waitFor(
+ () => {
+ expect(vscode.postMessage).toHaveBeenCalledWith({
+ type: "askResponse",
+ askResponse: "yesButtonClicked",
+ })
+ },
+ { timeout: 1000 },
+ )
})
it("does not auto-approve write operations when alwaysAllowWrite is enabled but message is not a tool request", () => {
@@ -490,7 +534,10 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send a non-tool write operation message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add non-tool write request
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowWrite: true,
@@ -503,15 +550,14 @@ describe("ChatView - Auto Approval Tests", () => {
},
{
type: "ask",
- ask: "write_operation",
+ ask: "write_to_file",
ts: Date.now(),
- text: JSON.stringify({ path: "test.txt", content: "test content" }),
- partial: false,
+ text: "Writing to test.txt",
},
],
})
- // Verify no auto-approval message was sent
+ // Should not auto-approve non-tool write operations
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
@@ -526,7 +572,7 @@ describe("ChatView - Auto Approval Tests", () => {
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
- allowedCommands: ["npm test"],
+ allowedCommands: ["npm test", "npm run build"],
clineMessages: [
{
type: "say",
@@ -537,11 +583,14 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send the command ask message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add allowed command
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
- allowedCommands: ["npm test"],
+ allowedCommands: ["npm test", "npm run build"],
clineMessages: [
{
type: "say",
@@ -554,12 +603,11 @@ describe("ChatView - Auto Approval Tests", () => {
ask: "command",
ts: Date.now(),
text: "npm test",
- partial: false,
},
],
})
- // Wait for the auto-approval message
+ // Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
@@ -586,7 +634,10 @@ describe("ChatView - Auto Approval Tests", () => {
],
})
- // Then send the disallowed command ask message
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add disallowed command
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
@@ -603,12 +654,11 @@ describe("ChatView - Auto Approval Tests", () => {
ask: "command",
ts: Date.now(),
text: "rm -rf /",
- partial: false,
},
],
})
- // Verify no auto-approval message was sent
+ // Should not auto-approve disallowed command
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
@@ -619,42 +669,38 @@ describe("ChatView - Auto Approval Tests", () => {
it("auto-approves chained commands when all parts are allowed", async () => {
renderChatView()
- // Test various allowed command chaining scenarios
- const allowedChainedCommands = [
+ // First hydrate state with initial task
+ mockPostMessage({
+ autoApprovalEnabled: true,
+ alwaysAllowExecute: true,
+ allowedCommands: ["npm test", "npm run build", "echo"],
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ ],
+ })
+
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Test various chained commands
+ const chainedCommands = [
"npm test && npm run build",
+ "npm test || echo 'test failed'",
"npm test; npm run build",
- "npm test || npm run build",
- "npm test | npm run build",
- // Add test for quoted pipes which should be treated as part of the command, not as a chain operator
- 'echo "hello | world"',
- 'npm test "param with | inside" && npm run build',
- // PowerShell command with Select-String
- 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
]
- for (const command of allowedChainedCommands) {
- vi.clearAllMocks()
+ for (const command of chainedCommands) {
+ vi.mocked(vscode.postMessage).mockClear()
- // First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowExecute: true,
- allowedCommands: ["npm test", "npm run build", "echo", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- ],
- })
-
- // Then send the chained command ask message
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "npm run build", "echo", "Select-String"],
+ allowedCommands: ["npm test", "npm run build", "echo"],
clineMessages: [
{
type: "say",
@@ -667,12 +713,11 @@ describe("ChatView - Auto Approval Tests", () => {
ask: "command",
ts: Date.now(),
text: command,
- partial: false,
},
],
})
- // Wait for the auto-approval message
+ // Wait for auto-approval to happen
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
@@ -685,188 +730,112 @@ describe("ChatView - Auto Approval Tests", () => {
it("does not auto-approve chained commands when any part is disallowed", () => {
renderChatView()
- // Test various command chaining scenarios with disallowed parts
- const disallowedChainedCommands = [
- "npm test && rm -rf /",
- "npm test; rm -rf /",
- "npm test || rm -rf /",
- "npm test | rm -rf /",
- // Test subshell execution using $() and backticks
- "npm test $(echo dangerous)",
- "npm test `echo dangerous`",
- // Test unquoted pipes with disallowed commands
- "npm test | rm -rf /",
- // Test PowerShell command with disallowed parts
- 'npm test 2>&1 | Select-String -NotMatch "node_modules" | rm -rf /',
- ]
+ // First hydrate state with initial task
+ mockPostMessage({
+ autoApprovalEnabled: true,
+ alwaysAllowExecute: true,
+ allowedCommands: ["npm test", "echo"],
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ ],
+ })
- disallowedChainedCommands.forEach((command) => {
- // First hydrate state with initial task
- mockPostMessage({
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- ],
- })
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
- // Then send the chained command ask message
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- {
- type: "ask",
- ask: "command",
- ts: Date.now(),
- text: command,
- partial: false,
- },
- ],
- })
+ // Add chained command with disallowed part
+ mockPostMessage({
+ autoApprovalEnabled: true,
+ alwaysAllowExecute: true,
+ allowedCommands: ["npm test", "echo"],
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "command",
+ ts: Date.now(),
+ text: "npm test && rm -rf /",
+ },
+ ],
+ })
- // Verify no auto-approval message was sent for chained commands with disallowed parts
- expect(vscode.postMessage).not.toHaveBeenCalledWith({
- type: "askResponse",
- askResponse: "yesButtonClicked",
- })
+ // Should not auto-approve chained command with disallowed part
+ expect(vscode.postMessage).not.toHaveBeenCalledWith({
+ type: "askResponse",
+ askResponse: "yesButtonClicked",
})
})
it("handles complex PowerShell command chains correctly", async () => {
renderChatView()
- // Test PowerShell specific command chains
- const powershellCommands = {
- allowed: [
- 'npm test 2>&1 | Select-String -NotMatch "node_modules"',
- 'npm test 2>&1 | Select-String "FAIL|Error"',
- 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Select-String "FAIL|Error"',
+ // First hydrate state with initial task
+ mockPostMessage({
+ autoApprovalEnabled: true,
+ alwaysAllowExecute: true,
+ allowedCommands: ["Get-Process", "Where-Object", "Select-Object"],
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
],
- disallowed: [
- 'npm test 2>&1 | Select-String -NotMatch "node_modules" | rm -rf /',
- 'npm test 2>&1 | Select-String "FAIL|Error" && del /F /Q *',
- 'npm test 2>&1 | Select-String -NotMatch "node_modules" | Remove-Item -Recurse',
+ })
+
+ // Clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+
+ // Add PowerShell piped command
+ mockPostMessage({
+ autoApprovalEnabled: true,
+ alwaysAllowExecute: true,
+ allowedCommands: ["Get-Process", "Where-Object", "Select-Object"],
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Initial task",
+ },
+ {
+ type: "ask",
+ ask: "command",
+ ts: Date.now(),
+ text: "Get-Process | Where-Object {$_.CPU -gt 10} | Select-Object Name, CPU",
+ },
],
- }
+ })
- // Test allowed PowerShell commands
- for (const command of powershellCommands.allowed) {
- vi.clearAllMocks()
-
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- ],
- })
-
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- {
- type: "ask",
- ask: "command",
- ts: Date.now(),
- text: command,
- partial: false,
- },
- ],
- })
-
- await waitFor(() => {
- expect(vscode.postMessage).toHaveBeenCalledWith({
- type: "askResponse",
- askResponse: "yesButtonClicked",
- })
- })
- }
-
- // Test disallowed PowerShell commands
- for (const command of powershellCommands.disallowed) {
- vi.clearAllMocks()
-
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- ],
- })
-
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowExecute: true,
- allowedCommands: ["npm test", "Select-String"],
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now() - 2000,
- text: "Initial task",
- },
- {
- type: "ask",
- ask: "command",
- ts: Date.now(),
- text: command,
- partial: false,
- },
- ],
- })
-
- expect(vscode.postMessage).not.toHaveBeenCalledWith({
+ // Wait for auto-approval to happen
+ await waitFor(() => {
+ expect(vscode.postMessage).toHaveBeenCalledWith({
type: "askResponse",
askResponse: "yesButtonClicked",
})
- }
+ })
})
})
})
describe("ChatView - Sound Playing Tests", () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockPlayFunction.mockClear()
- })
+ beforeEach(() => vi.clearAllMocks())
- it("does not play sound for auto-approved browser actions", async () => {
+ it("does not play sound for auto-approved browser actions", () => {
renderChatView()
- // First hydrate state with initial task and streaming
+ // First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
@@ -877,17 +846,13 @@ describe("ChatView - Sound Playing Tests", () => {
ts: Date.now() - 2000,
text: "Initial task",
},
- {
- type: "say",
- say: "api_req_started",
- ts: Date.now() - 1000,
- text: JSON.stringify({}),
- partial: true,
- },
],
})
- // Then send the browser action ask message (streaming finished)
+ // Clear any initial calls
+ mockPlayFunction.mockClear()
+
+ // Add browser action that will be auto-approved
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: true,
@@ -903,22 +868,22 @@ describe("ChatView - Sound Playing Tests", () => {
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
- partial: false,
},
],
})
- // Verify no sound was played
+ // Should not play sound for auto-approved action
expect(mockPlayFunction).not.toHaveBeenCalled()
})
it("plays notification sound for non-auto-approved browser actions", async () => {
renderChatView()
- // First hydrate state with initial task and streaming
+ // First hydrate state with initial task
mockPostMessage({
autoApprovalEnabled: true,
- alwaysAllowBrowser: false,
+ alwaysAllowBrowser: false, // Browser actions not auto-approved
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -926,20 +891,17 @@ describe("ChatView - Sound Playing Tests", () => {
ts: Date.now() - 2000,
text: "Initial task",
},
- {
- type: "say",
- say: "api_req_started",
- ts: Date.now() - 1000,
- text: JSON.stringify({}),
- partial: true,
- },
],
})
- // Then send the browser action ask message (streaming finished)
+ // Clear any initial calls
+ mockPlayFunction.mockClear()
+
+ // Add browser action that won't be auto-approved
mockPostMessage({
autoApprovalEnabled: true,
alwaysAllowBrowser: false,
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -952,12 +914,12 @@ describe("ChatView - Sound Playing Tests", () => {
ask: "browser_action_launch",
ts: Date.now(),
text: JSON.stringify({ action: "launch", url: "http://example.com" }),
- partial: false,
+ partial: false, // Ensure it's not partial
},
],
})
- // Verify notification sound was played
+ // Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
@@ -966,8 +928,9 @@ describe("ChatView - Sound Playing Tests", () => {
it("plays celebration sound for completion results", async () => {
renderChatView()
- // First hydrate state with initial task and streaming
+ // First hydrate state with initial task
mockPostMessage({
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -975,18 +938,15 @@ describe("ChatView - Sound Playing Tests", () => {
ts: Date.now() - 2000,
text: "Initial task",
},
- {
- type: "say",
- say: "api_req_started",
- ts: Date.now() - 1000,
- text: JSON.stringify({}),
- partial: true,
- },
],
})
- // Then send the completion result message (streaming finished)
+ // Clear any initial calls
+ mockPlayFunction.mockClear()
+
+ // Add completion result
mockPostMessage({
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -999,12 +959,12 @@ describe("ChatView - Sound Playing Tests", () => {
ask: "completion_result",
ts: Date.now(),
text: "Task completed successfully",
- partial: false,
+ partial: false, // Ensure it's not partial
},
],
})
- // Verify celebration sound was played
+ // Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
@@ -1013,8 +973,9 @@ describe("ChatView - Sound Playing Tests", () => {
it("plays progress_loop sound for api failures", async () => {
renderChatView()
- // First hydrate state with initial task and streaming
+ // First hydrate state with initial task
mockPostMessage({
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -1022,18 +983,15 @@ describe("ChatView - Sound Playing Tests", () => {
ts: Date.now() - 2000,
text: "Initial task",
},
- {
- type: "say",
- say: "api_req_started",
- ts: Date.now() - 1000,
- text: JSON.stringify({}),
- partial: true,
- },
],
})
- // Then send the api failure message (streaming finished)
+ // Clear any initial calls
+ mockPlayFunction.mockClear()
+
+ // Add API failure
mockPostMessage({
+ soundEnabled: true, // Enable sound
clineMessages: [
{
type: "say",
@@ -1046,46 +1004,72 @@ describe("ChatView - Sound Playing Tests", () => {
ask: "api_req_failed",
ts: Date.now(),
text: "API request failed",
- partial: false,
+ partial: false, // Ensure it's not partial
},
],
})
- // Verify progress_loop sound was played
+ // Wait for sound to be played
await waitFor(() => {
expect(mockPlayFunction).toHaveBeenCalled()
})
})
- it("does not play sound when resuming a task from history", async () => {
+ it("does not play sound when resuming a task from history", () => {
renderChatView()
+
+ // Clear any initial calls
mockPlayFunction.mockClear()
- // Send resume_task message
+ // Hydrate state with a task that has a resumeTaskId (indicating it's resumed from history)
mockPostMessage({
+ resumeTaskId: "task-123",
clineMessages: [
- { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task" },
- { type: "ask", ask: "resume_task", ts: Date.now(), text: "Resume task", partial: false },
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Resumed task",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ },
],
})
- await new Promise((resolve) => setTimeout(resolve, 100))
+ // Should not play sound when resuming from history
expect(mockPlayFunction).not.toHaveBeenCalled()
})
- it("does not play sound when resuming a completed task from history", async () => {
+ it("does not play sound when resuming a completed task from history", () => {
renderChatView()
+
+ // Clear any initial calls
mockPlayFunction.mockClear()
- // Send resume_completed_task message
+ // Hydrate state with a completed task that has a resumeTaskId
mockPostMessage({
+ resumeTaskId: "task-123",
clineMessages: [
- { type: "say", say: "task", ts: Date.now() - 2000, text: "Initial task" },
- { type: "ask", ask: "resume_completed_task", ts: Date.now(), text: "Resume completed", partial: false },
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 2000,
+ text: "Resumed task",
+ },
+ {
+ type: "ask",
+ ask: "completion_result",
+ ts: Date.now(),
+ text: "Task completed",
+ },
],
})
- await new Promise((resolve) => setTimeout(resolve, 100))
+ // Should not play sound for completion when resuming from history
expect(mockPlayFunction).not.toHaveBeenCalled()
})
})
@@ -1094,256 +1078,198 @@ describe("ChatView - Focus Grabbing Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not grab focus when follow-up question presented", async () => {
- const sleep = async (timeout: number) => {
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, timeout))
- })
- }
+ const { getByTestId } = renderChatView()
- renderChatView()
-
- // First hydrate state with initial task and streaming
+ // First hydrate state with initial task
mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
- ts: Date.now(),
+ ts: Date.now() - 2000,
text: "Initial task",
},
- {
- type: "say",
- say: "api_req_started",
- ts: Date.now(),
- text: JSON.stringify({}),
- partial: true,
- },
],
})
- // process messages
- await sleep(0)
- // wait for focus updates (can take 50msecs)
- await sleep(100)
+ // Clear any initial calls
+ mockFocus.mockClear()
- const FOCUS_CALLS_ON_INIT = 2
- expect(mockFocus).toHaveBeenCalledTimes(FOCUS_CALLS_ON_INIT)
-
- // Finish task, and send the followup ask message (streaming unfinished)
+ // Add follow-up question
mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowBrowser: true,
clineMessages: [
{
type: "say",
say: "task",
- ts: Date.now(),
+ ts: Date.now() - 2000,
text: "Initial task",
},
{
type: "ask",
ask: "followup",
ts: Date.now(),
- text: JSON.stringify({}),
- partial: true,
+ text: "Should I continue?",
},
],
})
- // allow messages to be processed
- await sleep(0)
-
- // Finish the followup ask message (streaming finished)
- mockPostMessage({
- autoApprovalEnabled: true,
- alwaysAllowBrowser: true,
- clineMessages: [
- {
- type: "ask",
- ask: "followup",
- ts: Date.now(),
- text: JSON.stringify({}),
- },
- ],
+ // Wait a bit to ensure any focus operations would have occurred
+ await waitFor(() => {
+ expect(getByTestId("chat-textarea")).toBeInTheDocument()
})
- // allow messages to be processed
- await sleep(0)
-
- // wait for focus updates (can take 50msecs)
- await sleep(100)
-
- // focus() should not have been called again
- expect(mockFocus).toHaveBeenCalledTimes(FOCUS_CALLS_ON_INIT)
+ // Should not grab focus for follow-up questions
+ expect(mockFocus).not.toHaveBeenCalled()
})
})
describe("ChatView - Version Indicator Tests", () => {
- beforeEach(() => vi.clearAllMocks())
-
- // Helper function to create a mock VersionIndicator implementation
- const createMockVersionIndicator = (
- ariaLabel: string = "chat:versionIndicator.ariaLabel",
- version: string = "v3.21.5",
- ) => {
- return (props?: { onClick?: () => void; className?: string }) => {
- const { onClick, className } = props || {}
- return (
-
- )
- }
- }
-
- it("displays version indicator button", () => {
- // Temporarily override the mock for this test
- mockVersionIndicator.mockImplementation(createMockVersionIndicator())
-
- const { getByLabelText } = renderChatView()
-
- // First hydrate state
- mockPostMessage({
- clineMessages: [],
- })
-
- // Check that version indicator is displayed
- const versionButton = getByLabelText(/version/i)
- expect(versionButton).toBeInTheDocument()
- expect(versionButton).toHaveTextContent(/^v\d+\.\d+\.\d+/)
-
- // Reset mock
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Reset the mock to return null by default
mockVersionIndicator.mockReturnValue(null)
})
- it("opens announcement modal when version indicator is clicked", () => {
- // Temporarily override the mock for this test
- mockVersionIndicator.mockImplementation(createMockVersionIndicator("Version 3.22.5", "v3.22.5"))
+ it("displays version indicator button", () => {
+ // Mock VersionIndicator to return a button
+ mockVersionIndicator.mockReturnValue(
+ React.createElement("button", {
+ "data-testid": "version-indicator",
+ "aria-label": "Version 1.0.0",
+ className: "version-indicator-button",
+ }),
+ )
const { getByTestId } = renderChatView()
- // First hydrate state
+ // Hydrate state with no active task
mockPostMessage({
+ version: "1.0.0",
clineMessages: [],
})
- // Find version indicator
- const versionButton = getByTestId("version-indicator")
- expect(versionButton).toBeInTheDocument()
+ // Should display version indicator
+ expect(getByTestId("version-indicator")).toBeInTheDocument()
+ })
- // Click should trigger modal - we'll just verify the button exists and is clickable
- // The actual modal rendering is handled by the component state
- expect(versionButton.onclick).toBeDefined()
+ it("opens announcement modal when version indicator is clicked", async () => {
+ // Mock VersionIndicator to return a button with onClick
+ mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) =>
+ React.createElement("button", {
+ "data-testid": "version-indicator",
+ onClick,
+ }),
+ )
- // Reset mock
- mockVersionIndicator.mockReturnValue(null)
+ const { getByTestId, queryByTestId } = renderChatView({ showAnnouncement: false })
+
+ // Hydrate state
+ mockPostMessage({
+ version: "1.0.0",
+ clineMessages: [],
+ })
+
+ // Wait for component to render
+ await waitFor(() => {
+ expect(getByTestId("version-indicator")).toBeInTheDocument()
+ })
+
+ // Click version indicator
+ const versionIndicator = getByTestId("version-indicator")
+ act(() => {
+ versionIndicator.click()
+ })
+
+ // Wait for announcement modal to appear
+ await waitFor(() => {
+ expect(queryByTestId("announcement-modal")).toBeInTheDocument()
+ })
})
it("version indicator has correct styling classes", () => {
- // Temporarily override the mock for this test
- mockVersionIndicator.mockImplementation(createMockVersionIndicator("Version 3.22.5", "v3.22.5"))
+ // Mock VersionIndicator to return a button with specific classes
+ mockVersionIndicator.mockReturnValue(
+ React.createElement("button", {
+ "data-testid": "version-indicator",
+ className: "version-indicator-button absolute top-2 right-2",
+ }),
+ )
const { getByTestId } = renderChatView()
- // First hydrate state
+ // Hydrate state
mockPostMessage({
+ version: "1.0.0",
clineMessages: [],
})
- // Check styling classes - the VersionIndicator component receives className prop
- const versionButton = getByTestId("version-indicator")
- expect(versionButton).toBeInTheDocument()
- // The className is passed as a prop to VersionIndicator
- expect(versionButton.className).toContain("absolute top-2 right-3 z-10")
-
- // Reset mock
- mockVersionIndicator.mockReturnValue(null)
+ const versionIndicator = getByTestId("version-indicator")
+ expect(versionIndicator.className).toContain("version-indicator-button")
+ expect(versionIndicator.className).toContain("absolute")
+ expect(versionIndicator.className).toContain("top-2")
+ expect(versionIndicator.className).toContain("right-2")
})
it("version indicator has proper accessibility attributes", () => {
- // Temporarily override the mock for this test
- mockVersionIndicator.mockImplementation(createMockVersionIndicator("Version 3.22.5", "v3.22.5"))
+ // Mock VersionIndicator to return a button with aria-label
+ mockVersionIndicator.mockReturnValue(
+ React.createElement("button", {
+ "data-testid": "version-indicator",
+ "aria-label": "Version 1.0.0",
+ role: "button",
+ }),
+ )
const { getByTestId } = renderChatView()
- // First hydrate state
+ // Hydrate state
mockPostMessage({
+ version: "1.0.0",
clineMessages: [],
})
- // Check accessibility
- const versionButton = getByTestId("version-indicator")
- expect(versionButton).toBeInTheDocument()
- expect(versionButton).toHaveAttribute("aria-label", "Version 3.22.5")
-
- // Reset mock
- mockVersionIndicator.mockReturnValue(null)
+ const versionIndicator = getByTestId("version-indicator")
+ expect(versionIndicator.getAttribute("aria-label")).toBe("Version 1.0.0")
+ expect(versionIndicator.getAttribute("role")).toBe("button")
})
it("does not display version indicator when there is an active task", () => {
+ // Mock VersionIndicator to return null (simulating hidden state)
+ mockVersionIndicator.mockReturnValue(null)
+
const { queryByTestId } = renderChatView()
- // Hydrate state with an active task - any message in the array makes task truthy
+ // Hydrate state with active task
mockPostMessage({
+ version: "1.0.0",
clineMessages: [
{
type: "say",
say: "task",
ts: Date.now(),
- text: "Active task in progress",
+ text: "Active task",
},
],
})
- // Version indicator should not be present during task execution
- const versionButton = queryByTestId("version-indicator")
- expect(versionButton).not.toBeInTheDocument()
+ // Should not display version indicator during active task
+ expect(queryByTestId("version-indicator")).not.toBeInTheDocument()
})
it("displays version indicator only on welcome screen (no task)", () => {
- // Temporarily override the mock for this test
- mockVersionIndicator.mockImplementation(createMockVersionIndicator("Version 3.22.5", "v3.22.5"))
+ // Mock VersionIndicator to return a button
+ mockVersionIndicator.mockReturnValue(React.createElement("button", { "data-testid": "version-indicator" }))
- const { queryByTestId, rerender } = renderChatView()
+ const { queryByTestId } = renderChatView()
- // First, hydrate with no messages (welcome screen)
+ // Hydrate state with no active task
mockPostMessage({
+ version: "1.0.0",
clineMessages: [],
})
- // Version indicator should be present
- let versionButton = queryByTestId("version-indicator")
- expect(versionButton).toBeInTheDocument()
-
- // Reset mock to return null for the second part of the test
- mockVersionIndicator.mockReturnValue(null)
-
- // Now add a task - any message makes task truthy
- mockPostMessage({
- clineMessages: [
- {
- type: "say",
- say: "task",
- ts: Date.now(),
- text: "Starting a new task",
- },
- ],
- })
-
- // Force a re-render to ensure the component updates
- rerender(
-
-
-
-
- ,
- )
-
- // Version indicator should disappear
- versionButton = queryByTestId("version-indicator")
- expect(versionButton).not.toBeInTheDocument()
+ // Should display version indicator on welcome screen
+ expect(queryByTestId("version-indicator")).toBeInTheDocument()
})
})
@@ -1351,30 +1277,28 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
beforeEach(() => vi.clearAllMocks())
it("does not show RooCloudCTA when user is authenticated to Cloud", () => {
- const { queryByTestId, getByTestId } = renderChatView()
+ const { queryByTestId } = renderChatView()
- // Hydrate state with user authenticated to cloud and some task history
+ // Hydrate state with user authenticated to cloud
mockPostMessage({
cloudIsAuthenticated: true,
taskHistory: [
- { id: "1", ts: Date.now() - 4000 },
- { id: "2", ts: Date.now() - 3000 },
- { id: "3", ts: Date.now() - 2000 },
- { id: "4", ts: Date.now() - 1000 },
- { id: "5", ts: Date.now() },
+ { id: "1", ts: Date.now() - 3000 },
+ { id: "2", ts: Date.now() - 2000 },
+ { id: "3", ts: Date.now() - 1000 },
+ { id: "4", ts: Date.now() },
],
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA but should show RooTips
+ // Should not show RooCloudCTA when authenticated
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
- expect(getByTestId("roo-tips")).toBeInTheDocument()
})
it("does not show RooCloudCTA when user has only run 3 tasks in their history", () => {
- const { queryByTestId, getByTestId } = renderChatView()
+ const { queryByTestId } = renderChatView()
- // Hydrate state with user not authenticated and only 3 tasks in history
+ // Hydrate state with user not authenticated but only 3 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
@@ -1385,15 +1309,14 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should not show RooCloudCTA but should show RooTips
+ // Should not show RooCloudCTA with less than 4 tasks
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
- expect(getByTestId("roo-tips")).toBeInTheDocument()
})
it("shows RooCloudCTA when user is not authenticated and has run 4 or more tasks", async () => {
- const { getByTestId, queryByTestId } = renderChatView()
+ const { getByTestId } = renderChatView()
- // Hydrate state with user not authenticated and 4+ tasks in history
+ // Hydrate state with user not authenticated and 4 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
@@ -1405,17 +1328,16 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should show RooCloudCTA and not RooTips
+ // Wait for component to render and show RooCloudCTA
await waitFor(() => {
expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
})
- expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
})
it("shows RooCloudCTA when user is not authenticated and has run 5 tasks", async () => {
- const { getByTestId, queryByTestId } = renderChatView()
+ const { getByTestId } = renderChatView()
- // Hydrate state with user not authenticated and 5 tasks in history
+ // Hydrate state with user not authenticated and 5 tasks
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
@@ -1428,17 +1350,16 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
clineMessages: [], // No active task
})
- // Should show RooCloudCTA and not RooTips
+ // Wait for component to render and show RooCloudCTA
await waitFor(() => {
expect(getByTestId("roo-cloud-cta")).toBeInTheDocument()
})
- expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
})
it("does not show RooCloudCTA when there is an active task (regardless of auth status)", async () => {
const { queryByTestId } = renderChatView()
- // Hydrate state with user not authenticated, 4+ tasks, but with an active task
+ // Hydrate state with active task
mockPostMessage({
cloudIsAuthenticated: false,
taskHistory: [
@@ -1452,14 +1373,14 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
type: "say",
say: "task",
ts: Date.now(),
- text: "Active task in progress",
+ text: "Active task",
},
],
})
- // Wait for the state to be updated and the task view to be shown
+ // Wait for component to render with active task
await waitFor(() => {
- // Should not show RooCloudCTA when there's an active task
+ // Should not show RooCloudCTA during active task
expect(queryByTestId("roo-cloud-cta")).not.toBeInTheDocument()
// Should not show RooTips either since the entire welcome screen is hidden during active tasks
expect(queryByTestId("roo-tips")).not.toBeInTheDocument()
@@ -1507,3 +1428,68 @@ describe("ChatView - RooCloudCTA Display Tests", () => {
expect(getByTestId("roo-tips")).toBeInTheDocument()
})
})
+
+describe("ChatView - Message Queueing Tests", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Reset the mock to clear any initial calls
+ vi.mocked(vscode.postMessage).mockClear()
+ })
+
+ it("shows sending is disabled when task is active", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Hydrate state with active task that should disable sending
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "say",
+ say: "task",
+ ts: Date.now() - 1000,
+ text: "Task in progress",
+ },
+ {
+ type: "ask",
+ ask: "tool",
+ ts: Date.now(),
+ text: JSON.stringify({ tool: "readFile", path: "test.txt" }),
+ partial: true, // Partial messages disable sending
+ },
+ ],
+ })
+
+ // Wait for state to be updated and check that sending is disabled
+ await waitFor(() => {
+ const chatTextArea = getByTestId("chat-textarea")
+ const input = chatTextArea.querySelector("input")!
+ expect(input.getAttribute("data-sending-disabled")).toBe("true")
+ })
+ })
+
+ it("shows sending is enabled when no task is active", async () => {
+ const { getByTestId } = renderChatView()
+
+ // Hydrate state with completed task
+ mockPostMessage({
+ clineMessages: [
+ {
+ type: "ask",
+ ask: "completion_result",
+ ts: Date.now(),
+ text: "Task completed",
+ partial: false,
+ },
+ ],
+ })
+
+ // Wait for state to be updated
+ await waitFor(() => {
+ expect(getByTestId("chat-textarea")).toBeInTheDocument()
+ })
+
+ // Check that sending is enabled
+ const chatTextArea = getByTestId("chat-textarea")
+ const input = chatTextArea.querySelector("input")!
+ expect(input.getAttribute("data-sending-disabled")).toBe("false")
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
new file mode 100644
index 0000000000..f59cb9a2ea
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
@@ -0,0 +1,560 @@
+import React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { describe, it, expect, vi, beforeEach } from "vitest"
+import { CommandExecution } from "../CommandExecution"
+import { ExtensionStateContext } from "../../../context/ExtensionStateContext"
+
+// Mock dependencies
+vi.mock("react-use", () => ({
+ useEvent: vi.fn(),
+}))
+
+import { vscode } from "../../../utils/vscode"
+
+vi.mock("../../../utils/vscode", () => ({
+ vscode: {
+ postMessage: vi.fn(),
+ },
+}))
+
+vi.mock("../../common/CodeBlock", () => ({
+ default: ({ source }: { source: string }) => {source}
,
+}))
+
+vi.mock("../CommandPatternSelector", () => ({
+ CommandPatternSelector: ({ command, onAllowPatternChange, onDenyPatternChange }: any) => (
+
+ {command}
+
+
+
+ ),
+}))
+
+// Mock ExtensionStateContext
+const mockExtensionState = {
+ terminalShellIntegrationDisabled: false,
+ allowedCommands: ["npm"],
+ deniedCommands: ["rm"],
+ setAllowedCommands: vi.fn(),
+ setDeniedCommands: vi.fn(),
+}
+
+const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => (
+ {children}
+)
+
+describe("CommandExecution", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("should render command without output", () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId("code-block")).toHaveTextContent("npm install")
+ })
+
+ it("should render command with output", () => {
+ render(
+
+
+ ,
+ )
+
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks[0]).toHaveTextContent("npm install")
+ })
+
+ it("should render with custom icon and title", () => {
+ const icon = 📦
+ const title = Installing Dependencies
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId("custom-icon")).toBeInTheDocument()
+ expect(screen.getByTestId("custom-title")).toBeInTheDocument()
+ })
+
+ it("should show command pattern selector for commands", () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
+ // Check that the command is shown in the pattern selector
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toHaveTextContent("npm install express")
+ })
+
+ it("should handle allow command change", () => {
+ render(
+
+
+ ,
+ )
+
+ const allowButton = screen.getByText("Allow git push")
+ fireEvent.click(allowButton)
+
+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git push"])
+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"])
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "git push"] })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
+ })
+
+ it("should handle deny command change", () => {
+ render(
+
+
+ ,
+ )
+
+ const denyButton = screen.getByText("Deny docker run")
+ fireEvent.click(denyButton)
+
+ expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"])
+ expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker run"])
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm", "docker run"] })
+ })
+
+ it("should toggle allowed command", () => {
+ // Update the mock state to have "npm test" in allowedCommands
+ const stateWithNpmTest = {
+ ...mockExtensionState,
+ allowedCommands: ["npm test"],
+ deniedCommands: ["rm"],
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const allowButton = screen.getByText("Allow npm test")
+ fireEvent.click(allowButton)
+
+ // "npm test" is already in allowedCommands, so it should be removed
+ expect(stateWithNpmTest.setAllowedCommands).toHaveBeenCalledWith([])
+ expect(stateWithNpmTest.setDeniedCommands).toHaveBeenCalledWith(["rm"])
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: [] })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: ["rm"] })
+ })
+
+ it("should toggle denied command", () => {
+ // Update the mock state to have "rm -rf" in deniedCommands
+ const stateWithRmRf = {
+ ...mockExtensionState,
+ allowedCommands: ["npm"],
+ deniedCommands: ["rm -rf"],
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const denyButton = screen.getByText("Deny rm -rf")
+ fireEvent.click(denyButton)
+
+ // "rm -rf" is already in deniedCommands, so it should be removed
+ expect(stateWithRmRf.setAllowedCommands).toHaveBeenCalledWith(["npm"])
+ expect(stateWithRmRf.setDeniedCommands).toHaveBeenCalledWith([])
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm"] })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
+ })
+
+ it("should parse command with Output: separator", () => {
+ const commandText = `npm install
+Output:
+Installing...`
+
+ render(
+
+
+ ,
+ )
+
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks[0]).toHaveTextContent("npm install")
+ })
+
+ it("should parse command with output", () => {
+ const commandText = `npm install
+Output:
+Suggested patterns: npm, npm install, npm run`
+
+ render(
+
+
+ ,
+ )
+
+ // First check that the command was parsed correctly
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks[0]).toHaveTextContent("npm install")
+ expect(codeBlocks[1]).toHaveTextContent("Suggested patterns: npm, npm install, npm run")
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ // Should show the full command in the selector
+ expect(selector).toHaveTextContent("npm install")
+ })
+
+ it("should handle commands with pipes", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("ls -la | grep test")
+ })
+
+ it("should handle commands with && operator", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("npm install && npm test")
+ })
+
+ it("should not show pattern selector for empty commands", () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
+ })
+
+ it("should expand output when terminal shell integration is disabled", () => {
+ const disabledState = {
+ ...mockExtensionState,
+ terminalShellIntegrationDisabled: true,
+ }
+
+ const commandText = `npm install
+Output:
+Output here`
+
+ render(
+
+
+ ,
+ )
+
+ // Output should be visible when shell integration is disabled
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks).toHaveLength(2) // Command and output blocks
+ expect(codeBlocks[1]).toHaveTextContent("Output here")
+ })
+
+ it("should handle undefined allowedCommands and deniedCommands", () => {
+ const stateWithUndefined = {
+ ...mockExtensionState,
+ allowedCommands: undefined,
+ deniedCommands: undefined,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Should show pattern selector when patterns are available
+ expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
+ })
+
+ it("should handle command change when moving from denied to allowed", () => {
+ // Update the mock state to have "rm file.txt" in deniedCommands
+ const stateWithRmInDenied = {
+ ...mockExtensionState,
+ allowedCommands: ["npm"],
+ deniedCommands: ["rm file.txt"],
+ }
+
+ render(
+
+
+ ,
+ )
+
+ const allowButton = screen.getByText("Allow rm file.txt")
+ fireEvent.click(allowButton)
+
+ // "rm file.txt" should be removed from denied and added to allowed
+ expect(stateWithRmInDenied.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm file.txt"])
+ expect(stateWithRmInDenied.setDeniedCommands).toHaveBeenCalledWith([])
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "allowedCommands", commands: ["npm", "rm file.txt"] })
+ expect(vscode.postMessage).toHaveBeenCalledWith({ type: "deniedCommands", commands: [] })
+ })
+
+ describe("integration with CommandPatternSelector", () => {
+ it("should show complex commands with multiple operators", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("npm install && npm test || echo 'failed'")
+ })
+
+ it("should handle commands with output", () => {
+ const commandWithOutput = `npm install
+Output:
+Installing packages...
+Other output here`
+
+ render(
+
+ icon}
+ title={Run Command}
+ />
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ // Should show the command in the selector
+ expect(selector).toHaveTextContent("npm install")
+ })
+
+ it("should handle commands with subshells", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("echo $(whoami) && git status")
+ })
+
+ it("should handle commands with backtick subshells", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("git commit -m `date`")
+ })
+
+ it("should handle commands with special characters", () => {
+ render(
+
+
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("cd ~/projects && npm start")
+ })
+
+ it("should handle commands with mixed content including output", () => {
+ const commandWithMixedContent = `npm test
+Output:
+Running tests...
+✓ Test 1 passed
+✓ Test 2 passed`
+
+ render(
+
+ icon}
+ title={Run Command}
+ />
+ ,
+ )
+
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ // Should show the command in the selector
+ expect(selector).toHaveTextContent("npm test")
+ })
+
+ it("should update both allowed and denied lists when commands conflict", () => {
+ const conflictState = {
+ ...mockExtensionState,
+ allowedCommands: ["git"],
+ deniedCommands: ["git push origin main"],
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Click to allow "git push origin main"
+ const allowButton = screen.getByText("Allow git push origin main")
+ fireEvent.click(allowButton)
+
+ // Should add to allowed and remove from denied
+ expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push origin main"])
+ expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([])
+ })
+
+ it("should handle commands with special quotes", () => {
+ // Test with a command that has quotes
+ const commandWithQuotes = "echo 'test with unclosed quote"
+
+ render(
+
+
+ ,
+ )
+
+ // Should still render the command
+ expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote")
+
+ // Should show pattern selector with the full command
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("echo 'test with unclosed quote")
+ })
+
+ it("should handle empty or whitespace-only commands", () => {
+ render(
+
+
+ ,
+ )
+
+ // Should render without errors
+ expect(screen.getByTestId("code-block")).toBeInTheDocument()
+
+ // Should not show pattern selector for empty commands
+ expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
+ })
+
+ it("should handle commands with only output and no command prefix", () => {
+ const outputOnly = `Some output without a command
+Multiple lines of output
+Without any command prefix`
+
+ render(
+
+
+ ,
+ )
+
+ // Should treat the entire text as command when no prefix is found
+ const codeBlock = screen.getByTestId("code-block")
+ // The mock CodeBlock component renders text content without preserving newlines
+ expect(codeBlock.textContent).toContain("Some output without a command")
+ expect(codeBlock.textContent).toContain("Multiple lines of output")
+ expect(codeBlock.textContent).toContain("Without any command prefix")
+ })
+
+ it("should handle simple commands", () => {
+ const plainCommand = "docker build ."
+
+ render(
+
+
+ ,
+ )
+
+ // Should render the command
+ expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .")
+
+ // Should show pattern selector with the full command
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+ expect(selector).toHaveTextContent("docker build .")
+
+ // Verify no output is shown (since there's no Output: separator)
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks).toHaveLength(1) // Only the command block, no output block
+ })
+
+ it("should handle commands with numeric output", () => {
+ const commandWithNumericOutput = `wc -l *.go *.java
+Output:
+ 10 file1.go
+ 20 file2.go
+ 15 Main.java
+ 45 total`
+
+ render(
+
+
+ ,
+ )
+
+ // Should render the command and output
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks[0]).toHaveTextContent("wc -l *.go *.java")
+
+ // Should show pattern selector
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+
+ // Should show the full command in the selector
+ expect(selector).toHaveTextContent("wc -l *.go *.java")
+
+ // The output should still be displayed in the code block
+ expect(codeBlocks.length).toBeGreaterThan(1)
+ expect(codeBlocks[1].textContent).toContain("45 total")
+ })
+
+ it("should handle commands with zero output", () => {
+ const commandWithZeroTotal = `wc -l *.go *.java
+Output:
+ 0 total`
+
+ render(
+
+
+ ,
+ )
+
+ // Should show pattern selector
+ const selector = screen.getByTestId("command-pattern-selector")
+ expect(selector).toBeInTheDocument()
+
+ // Should show the full command in the selector
+ expect(selector).toHaveTextContent("wc -l *.go *.java")
+
+ // The output should still be displayed in the code block
+ const codeBlocks = screen.getAllByTestId("code-block")
+ expect(codeBlocks.length).toBeGreaterThan(1)
+ expect(codeBlocks[1]).toHaveTextContent("0 total")
+ })
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx
new file mode 100644
index 0000000000..18c5ddd5aa
--- /dev/null
+++ b/webview-ui/src/components/chat/__tests__/CommandPatternSelector.spec.tsx
@@ -0,0 +1,272 @@
+import React from "react"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { describe, it, expect, vi } from "vitest"
+import { CommandPatternSelector } from "../CommandPatternSelector"
+import { TooltipProvider } from "../../../components/ui/tooltip"
+
+// Mock react-i18next
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+ Trans: ({ i18nKey, children }: any) => {i18nKey || children},
+}))
+
+// Mock VSCodeLink
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+ VSCodeLink: ({ children, onClick }: any) => (
+
+ {children}
+
+ ),
+}))
+
+// Wrapper component with TooltipProvider
+const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}
+
+describe("CommandPatternSelector", () => {
+ const defaultProps = {
+ command: "npm install express",
+ patterns: [
+ { pattern: "npm install", description: "Install npm packages" },
+ { pattern: "npm *", description: "Any npm command" },
+ ],
+ allowedCommands: ["npm install"],
+ deniedCommands: ["git push"],
+ onAllowPatternChange: vi.fn(),
+ onDenyPatternChange: vi.fn(),
+ }
+
+ it("should render with command permissions header", () => {
+ const { container } = render(
+
+
+ ,
+ )
+
+ // The component should render without errors
+ expect(container).toBeTruthy()
+
+ // Check for the command permissions text
+ expect(screen.getByText("chat:commandExecution.manageCommands")).toBeInTheDocument()
+ })
+
+ it("should show full command as first pattern when expanded", () => {
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Check that the full command is shown
+ expect(screen.getByText("npm install express")).toBeInTheDocument()
+ })
+
+ it("should show extracted patterns when expanded", () => {
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Check that patterns are shown
+ expect(screen.getByText("npm install")).toBeInTheDocument()
+ expect(screen.getByText("- Install npm packages")).toBeInTheDocument()
+ expect(screen.getByText("npm *")).toBeInTheDocument()
+ expect(screen.getByText("- Any npm command")).toBeInTheDocument()
+ })
+
+ it("should allow editing patterns when clicked", () => {
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Click on the full command pattern
+ const fullCommandDiv = screen.getByText("npm install express").closest("div")
+ fireEvent.click(fullCommandDiv!)
+
+ // An input should appear
+ const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
+ expect(input).toBeInTheDocument()
+
+ // Change the value
+ fireEvent.change(input, { target: { value: "npm install react" } })
+ expect(input.value).toBe("npm install react")
+ })
+
+ it("should show allowed status for patterns in allowed list", () => {
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Find the npm install pattern row
+ const npmInstallPattern = screen.getByText("npm install").closest(".ml-5")
+
+ // The allow button should have the active styling (we can check by aria-label)
+ const allowButton = npmInstallPattern?.querySelector('button[aria-label*="removeFromAllowed"]')
+ expect(allowButton).toBeInTheDocument()
+ })
+
+ it("should show denied status for patterns in denied list", () => {
+ const props = {
+ ...defaultProps,
+ patterns: [{ pattern: "git push", description: "Push to git" }],
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Find the git push pattern row
+ const gitPushPattern = screen.getByText("git push").closest(".ml-5")
+
+ // The deny button should have the active styling (we can check by aria-label)
+ const denyButton = gitPushPattern?.querySelector('button[aria-label*="removeFromDenied"]')
+ expect(denyButton).toBeInTheDocument()
+ })
+
+ it("should call onAllowPatternChange when allow button is clicked", () => {
+ const mockOnAllowPatternChange = vi.fn()
+ const props = {
+ ...defaultProps,
+ onAllowPatternChange: mockOnAllowPatternChange,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Find the full command pattern row and click allow
+ const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
+ const allowButton = fullCommandPattern?.querySelector('button[aria-label*="addToAllowed"]')
+ fireEvent.click(allowButton!)
+
+ // Check that the callback was called with the pattern
+ expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install express")
+ })
+
+ it("should call onDenyPatternChange when deny button is clicked", () => {
+ const mockOnDenyPatternChange = vi.fn()
+ const props = {
+ ...defaultProps,
+ onDenyPatternChange: mockOnDenyPatternChange,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Find the full command pattern row and click deny
+ const fullCommandPattern = screen.getByText("npm install express").closest(".ml-5")
+ const denyButton = fullCommandPattern?.querySelector('button[aria-label*="addToDenied"]')
+ fireEvent.click(denyButton!)
+
+ // Check that the callback was called with the pattern
+ expect(mockOnDenyPatternChange).toHaveBeenCalledWith("npm install express")
+ })
+
+ it("should use edited pattern value when buttons are clicked", () => {
+ const mockOnAllowPatternChange = vi.fn()
+ const props = {
+ ...defaultProps,
+ onAllowPatternChange: mockOnAllowPatternChange,
+ }
+
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Click on the full command pattern to edit
+ const fullCommandDiv = screen.getByText("npm install express").closest("div")
+ fireEvent.click(fullCommandDiv!)
+
+ // Edit the command
+ const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
+ fireEvent.change(input, { target: { value: "npm install react" } })
+
+ // Don't press Enter or blur - just click the button while still editing
+ // This simulates the user clicking the button while the input is still focused
+
+ // Find the allow button in the same row as the input
+ const patternRow = input.closest(".ml-5")
+ const allowButton = patternRow?.querySelector('button[aria-label*="addToAllowed"]')
+ expect(allowButton).toBeInTheDocument()
+
+ // Click the allow button - this should use the current edited value
+ fireEvent.click(allowButton!)
+
+ // Check that the callback was called with the edited pattern
+ expect(mockOnAllowPatternChange).toHaveBeenCalledWith("npm install react")
+ })
+
+ it("should cancel edit on Escape key", () => {
+ render(
+
+
+ ,
+ )
+
+ // Click to expand the component
+ const expandButton = screen.getByRole("button")
+ fireEvent.click(expandButton)
+
+ // Click on the full command pattern to edit
+ const fullCommandDiv = screen.getByText("npm install express").closest("div")
+ fireEvent.click(fullCommandDiv!)
+
+ // Edit the command
+ const input = screen.getByDisplayValue("npm install express") as HTMLInputElement
+ fireEvent.change(input, { target: { value: "npm install react" } })
+
+ // Press Escape to cancel
+ fireEvent.keyDown(input, { key: "Escape" })
+
+ // The original value should be restored
+ expect(screen.getByText("npm install express")).toBeInTheDocument()
+ expect(screen.queryByDisplayValue("npm install react")).not.toBeInTheDocument()
+ })
+})
diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx
index d6fc81368d..a829168893 100644
--- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx
+++ b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx
@@ -1,8 +1,9 @@
import React from "react"
-import { render, screen } from "@/utils/test-utils"
+import { render, screen, fireEvent } from "@/utils/test-utils"
import { describe, test, expect, vi } from "vitest"
import ModeSelector from "../ModeSelector"
import { Mode } from "@roo/modes"
+import { ModeConfig } from "@roo-code/types"
// Mock the dependencies
vi.mock("@/utils/vscode", () => ({
@@ -28,6 +29,23 @@ vi.mock("@/components/ui/hooks/useRooPortal", () => ({
useRooPortal: () => document.body,
}))
+vi.mock("@/utils/TelemetryClient", () => ({
+ telemetryClient: {
+ capture: vi.fn(),
+ },
+}))
+
+// Create a variable to control what getAllModes returns
+let mockModes: ModeConfig[] = []
+
+vi.mock("@roo/modes", async () => {
+ const actual = await vi.importActual("@roo/modes")
+ return {
+ ...actual,
+ getAllModes: () => mockModes,
+ }
+})
+
describe("ModeSelector", () => {
test("shows custom description from customModePrompts", () => {
const customModePrompts = {
@@ -55,4 +73,130 @@ describe("ModeSelector", () => {
// The component should be rendered
expect(screen.getByTestId("mode-selector-trigger")).toBeInTheDocument()
})
+
+ test("shows search bar when there are more than 6 modes", () => {
+ // Set up mock to return 7 modes
+ mockModes = Array.from({ length: 7 }, (_, i) => ({
+ slug: `mode-${i}`,
+ name: `Mode ${i}`,
+ description: `Description for mode ${i}`,
+ roleDefinition: "Role definition",
+ groups: ["read", "edit"],
+ }))
+
+ render()
+
+ // Click to open the popover
+ fireEvent.click(screen.getByTestId("mode-selector-trigger"))
+
+ // Search input should be visible
+ expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
+
+ // Info icon should be visible
+ expect(screen.getByText("chat:modeSelector.title")).toBeInTheDocument()
+ const infoIcon = document.querySelector(".codicon-info")
+ expect(infoIcon).toBeInTheDocument()
+ })
+
+ test("shows info blurb instead of search bar when there are 6 or fewer modes", () => {
+ // Set up mock to return 5 modes
+ mockModes = Array.from({ length: 5 }, (_, i) => ({
+ slug: `mode-${i}`,
+ name: `Mode ${i}`,
+ description: `Description for mode ${i}`,
+ roleDefinition: "Role definition",
+ groups: ["read", "edit"],
+ }))
+
+ render()
+
+ // Click to open the popover
+ fireEvent.click(screen.getByTestId("mode-selector-trigger"))
+
+ // Search input should NOT be visible
+ expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
+
+ // Info blurb should be visible
+ expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
+
+ // Info icon should NOT be visible
+ const infoIcon = document.querySelector(".codicon-info")
+ expect(infoIcon).not.toBeInTheDocument()
+ })
+
+ test("filters modes correctly when searching", () => {
+ // Set up mock to return 7 modes to enable search
+ mockModes = Array.from({ length: 7 }, (_, i) => ({
+ slug: `mode-${i}`,
+ name: `Mode ${i}`,
+ description: `Description for mode ${i}`,
+ roleDefinition: "Role definition",
+ groups: ["read", "edit"],
+ }))
+
+ render()
+
+ // Click to open the popover
+ fireEvent.click(screen.getByTestId("mode-selector-trigger"))
+
+ // Type in search
+ const searchInput = screen.getByTestId("mode-search-input")
+ fireEvent.change(searchInput, { target: { value: "Mode 3" } })
+
+ // Should show filtered results
+ const modeItems = screen.getAllByTestId("mode-selector-item")
+ expect(modeItems.length).toBeLessThan(7) // Should have filtered some out
+ })
+
+ test("respects disableSearch prop even when there are more than 6 modes", () => {
+ // Set up mock to return 10 modes
+ mockModes = Array.from({ length: 10 }, (_, i) => ({
+ slug: `mode-${i}`,
+ name: `Mode ${i}`,
+ description: `Description for mode ${i}`,
+ roleDefinition: "Role definition",
+ groups: ["read", "edit"],
+ }))
+
+ render(
+ ,
+ )
+
+ // Click to open the popover
+ fireEvent.click(screen.getByTestId("mode-selector-trigger"))
+
+ // Search input should NOT be visible even with 10 modes
+ expect(screen.queryByTestId("mode-search-input")).not.toBeInTheDocument()
+
+ // Info blurb should be visible instead
+ expect(screen.getByText(/chat:modeSelector.description/)).toBeInTheDocument()
+
+ // Info icon should NOT be visible
+ const infoIcon = document.querySelector(".codicon-info")
+ expect(infoIcon).not.toBeInTheDocument()
+ })
+
+ test("shows search when disableSearch is false (default) and modes > 6", () => {
+ // Set up mock to return 8 modes
+ mockModes = Array.from({ length: 8 }, (_, i) => ({
+ slug: `mode-${i}`,
+ name: `Mode ${i}`,
+ description: `Description for mode ${i}`,
+ roleDefinition: "Role definition",
+ groups: ["read", "edit"],
+ }))
+
+ // Don't pass disableSearch prop (should default to false)
+ render()
+
+ // Click to open the popover
+ fireEvent.click(screen.getByTestId("mode-selector-trigger"))
+
+ // Search input should be visible
+ expect(screen.getByTestId("mode-search-input")).toBeInTheDocument()
+
+ // Info icon should be visible
+ const infoIcon = document.querySelector(".codicon-info")
+ expect(infoIcon).toBeInTheDocument()
+ })
})
diff --git a/webview-ui/src/components/common/MarkdownBlock.tsx b/webview-ui/src/components/common/MarkdownBlock.tsx
index fe033efe3f..cae609d955 100644
--- a/webview-ui/src/components/common/MarkdownBlock.tsx
+++ b/webview-ui/src/components/common/MarkdownBlock.tsx
@@ -1,12 +1,12 @@
-import React, { memo, useEffect } from "react"
-import { useRemark } from "react-remark"
+import React, { memo, useMemo } from "react"
+import ReactMarkdown from "react-markdown"
import styled from "styled-components"
import { visit } from "unist-util-visit"
import rehypeKatex from "rehype-katex"
import remarkMath from "remark-math"
+import remarkGfm from "remark-gfm"
import { vscode } from "@src/utils/vscode"
-import { useExtensionState } from "@src/context/ExtensionStateContext"
import CodeBlock from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"
@@ -15,68 +15,6 @@ interface MarkdownBlockProps {
markdown?: string
}
-/**
- * Custom remark plugin that converts plain URLs in text into clickable links
- *
- * The original bug: We were converting text nodes into paragraph nodes,
- * which broke the markdown structure because text nodes should remain as text nodes
- * within their parent elements (like paragraphs, list items, etc.).
- * This caused the entire content to disappear because the structure became invalid.
- */
-const remarkUrlToLink = () => {
- return (tree: any) => {
- // Visit all "text" nodes in the markdown AST (Abstract Syntax Tree)
- visit(tree, "text", (node: any, index, parent) => {
- const urlRegex = /https?:\/\/[^\s<>)"]+/g
- const matches = node.value.match(urlRegex)
-
- if (!matches || !parent) {
- return
- }
-
- const parts = node.value.split(urlRegex)
- const children: any[] = []
- const cleanedMatches = matches.map((url: string) => url.replace(/[.,;:!?'"]+$/, ""))
-
- parts.forEach((part: string, i: number) => {
- if (part) {
- children.push({ type: "text", value: part })
- }
-
- if (cleanedMatches[i]) {
- const originalUrl = matches[i]
- const cleanedUrl = cleanedMatches[i]
- const removedPunctuation = originalUrl.substring(cleanedUrl.length)
-
- // Create a proper link node with all required properties
- children.push({
- type: "link",
- url: cleanedUrl,
- title: null,
- children: [{ type: "text", value: cleanedUrl }],
- data: {
- hProperties: {
- href: cleanedUrl,
- },
- },
- })
-
- if (removedPunctuation) {
- children.push({ type: "text", value: removedPunctuation })
- }
- }
- })
-
- // Replace the original text node with our new nodes in the parent's children array.
- // This preserves the document structure while adding our links.
- parent.children.splice(index!, 1, ...children)
-
- // Return SKIP to prevent visiting the newly created nodes
- return ["skip", index! + children.length]
- })
- }
-}
-
const StyledMarkdown = styled.div`
code:not(pre > code) {
font-family: var(--vscode-editor-font-family, monospace);
@@ -151,8 +89,46 @@ const StyledMarkdown = styled.div`
margin-left: 0;
}
+ ol {
+ list-style-type: decimal;
+ }
+
+ ul {
+ list-style-type: disc;
+ }
+
+ /* Nested list styles */
+ ul ul {
+ list-style-type: circle;
+ }
+
+ ul ul ul {
+ list-style-type: square;
+ }
+
+ ol ol {
+ list-style-type: lower-alpha;
+ }
+
+ ol ol ol {
+ list-style-type: lower-roman;
+ }
+
p {
white-space: pre-wrap;
+ margin: 0.5em 0;
+ }
+
+ /* Prevent layout shifts during streaming */
+ pre {
+ min-height: 3em;
+ transition: height 0.2s ease-out;
+ }
+
+ /* Code block container styling */
+ div:has(> pre) {
+ position: relative;
+ contain: layout style;
}
a {
@@ -166,117 +142,170 @@ const StyledMarkdown = styled.div`
text-decoration-color: var(--vscode-textLink-activeForeground);
}
}
+
+ /* Table styles for remark-gfm */
+ table {
+ border-collapse: collapse;
+ margin: 1em 0;
+ width: auto;
+ min-width: 50%;
+ max-width: 100%;
+ table-layout: fixed;
+ }
+
+ /* Table wrapper for horizontal scrolling */
+ .table-wrapper {
+ overflow-x: auto;
+ margin: 1em 0;
+ }
+
+ th,
+ td {
+ border: 1px solid var(--vscode-panel-border);
+ padding: 8px 12px;
+ text-align: left;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ }
+
+ th {
+ background-color: var(--vscode-editor-background);
+ font-weight: 600;
+ color: var(--vscode-foreground);
+ }
+
+ tr:nth-child(even) {
+ background-color: var(--vscode-editor-inactiveSelectionBackground);
+ }
+
+ tr:hover {
+ background-color: var(--vscode-list-hoverBackground);
+ }
`
const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
- const { theme } = useExtensionState()
- const [reactContent, setMarkdown] = useRemark({
- remarkPlugins: [
- remarkUrlToLink,
- remarkMath,
- () => {
- return (tree) => {
- visit(tree, "code", (node: any) => {
- if (!node.lang) {
- node.lang = "text"
- } else if (node.lang.includes(".")) {
- node.lang = node.lang.split(".").slice(-1)[0]
- }
+ const components = useMemo(
+ () => ({
+ table: ({ children, ...props }: any) => {
+ return (
+
+ )
+ },
+ a: ({ href, children, ...props }: any) => {
+ const handleClick = (e: React.MouseEvent) => {
+ // Only process file:// protocol or local file paths
+ const isLocalPath = href?.startsWith("file://") || href?.startsWith("/") || !href?.includes("://")
+
+ if (!isLocalPath) {
+ return
+ }
+
+ e.preventDefault()
+
+ // Handle absolute vs project-relative paths
+ let filePath = href.replace("file://", "")
+
+ // Extract line number if present
+ const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
+ let values = undefined
+ if (match) {
+ filePath = match[1]
+ values = { line: parseInt(match[2]) }
+ }
+
+ // Add ./ prefix if needed
+ if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
+ filePath = "./" + filePath
+ }
+
+ vscode.postMessage({
+ type: "openFile",
+ text: filePath,
+ values,
})
}
+
+ return (
+
+ {children}
+
+ )
},
- ],
- rehypePlugins: [rehypeKatex as any],
- rehypeReactOptions: {
- components: {
- a: ({ href, children, ...props }: any) => {
- const handleClick = (e: React.MouseEvent) => {
- // Only process file:// protocol or local file paths
- const isLocalPath = href.startsWith("file://") || href.startsWith("/") || !href.includes("://")
+ pre: ({ children, ..._props }: any) => {
+ // The structure from react-markdown v9 is: pre > code > text
+ const codeEl = children as React.ReactElement
- if (!isLocalPath) {
- return
- }
+ if (!codeEl || !codeEl.props) {
+ return {children}
+ }
- e.preventDefault()
+ const { className = "", children: codeChildren } = codeEl.props
- // Handle absolute vs project-relative paths
- let filePath = href.replace("file://", "")
-
- // Extract line number if present
- const match = filePath.match(/(.*):(\d+)(-\d+)?$/)
- let values = undefined
- if (match) {
- filePath = match[1]
- values = { line: parseInt(match[2]) }
- }
-
- // Add ./ prefix if needed
- if (!filePath.startsWith("/") && !filePath.startsWith("./")) {
- filePath = "./" + filePath
- }
-
- vscode.postMessage({
- type: "openFile",
- text: filePath,
- values,
- })
- }
+ // Get the actual code text
+ let codeString = ""
+ if (typeof codeChildren === "string") {
+ codeString = codeChildren
+ } else if (Array.isArray(codeChildren)) {
+ codeString = codeChildren.filter((child) => typeof child === "string").join("")
+ }
+ // Handle mermaid diagrams
+ if (className.includes("language-mermaid")) {
return (
-
- {children}
-
+
+
+
)
- },
- pre: ({ node: _, children }: any) => {
- // Check for Mermaid diagrams first
- if (Array.isArray(children) && children.length === 1 && React.isValidElement(children[0])) {
- const child = children[0] as React.ReactElement<{ className?: string }>
+ }
- if (child.props?.className?.includes("language-mermaid")) {
- return child
- }
- }
+ // Extract language from className
+ const match = /language-(\w+)/.exec(className)
+ const language = match ? match[1] : "text"
- // For all other code blocks, use CodeBlock with copy button
- const codeNode = children?.[0]
-
- if (!codeNode?.props?.children) {
- return null
- }
-
- const language =
- (Array.isArray(codeNode.props?.className)
- ? codeNode.props.className
- : [codeNode.props?.className]
- ).map((c: string) => c?.replace("language-", ""))[0] || "javascript"
-
- const rawText = codeNode.props.children[0] || ""
- return
- },
- code: (props: any) => {
- const className = props.className || ""
-
- if (className.includes("language-mermaid")) {
- const codeText = String(props.children || "")
- return
- }
-
- return
- },
+ // Wrap CodeBlock in a div to ensure proper separation
+ return (
+
+
+
+ )
},
- },
- })
-
- useEffect(() => {
- setMarkdown(markdown || "")
- }, [markdown, setMarkdown, theme])
+ code: ({ children, className, ...props }: any) => {
+ // This handles inline code
+ return (
+
+ {children}
+
+ )
+ },
+ }),
+ [],
+ )
return (
-
- {reactContent}
-
+
+ {
+ return (tree: any) => {
+ visit(tree, "code", (node: any) => {
+ if (!node.lang) {
+ node.lang = "text"
+ } else if (node.lang.includes(".")) {
+ node.lang = node.lang.split(".").slice(-1)[0]
+ }
+ })
+ }
+ },
+ ]}
+ rehypePlugins={[rehypeKatex as any]}
+ components={components}>
+ {markdown || ""}
+
+
)
})
diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
index ec97e4e667..38a0680b22 100644
--- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
+++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx
@@ -36,4 +36,84 @@ describe("MarkdownBlock", () => {
const paragraph = container.querySelector("p")
expect(paragraph?.textContent).toBe("Check out this link: https://example.com.")
})
+
+ it("should render unordered lists with proper styling", async () => {
+ const markdown = `Here are some items:
+- First item
+- Second item
+ - Nested item
+ - Another nested item`
+
+ const { container } = render()
+
+ // Wait for the content to be processed
+ await screen.findByText(/Here are some items/, { exact: false })
+
+ // Check that ul elements exist
+ const ulElements = container.querySelectorAll("ul")
+ expect(ulElements.length).toBeGreaterThan(0)
+
+ // Check that list items exist
+ const liElements = container.querySelectorAll("li")
+ expect(liElements.length).toBe(4)
+
+ // Verify the text content
+ expect(screen.getByText("First item")).toBeInTheDocument()
+ expect(screen.getByText("Second item")).toBeInTheDocument()
+ expect(screen.getByText("Nested item")).toBeInTheDocument()
+ expect(screen.getByText("Another nested item")).toBeInTheDocument()
+ })
+
+ it("should render ordered lists with proper styling", async () => {
+ const markdown = `And a numbered list:
+1. Step one
+2. Step two
+3. Step three`
+
+ const { container } = render()
+
+ // Wait for the content to be processed
+ await screen.findByText(/And a numbered list/, { exact: false })
+
+ // Check that ol elements exist
+ const olElements = container.querySelectorAll("ol")
+ expect(olElements.length).toBe(1)
+
+ // Check that list items exist
+ const liElements = container.querySelectorAll("li")
+ expect(liElements.length).toBe(3)
+
+ // Verify the text content
+ expect(screen.getByText("Step one")).toBeInTheDocument()
+ expect(screen.getByText("Step two")).toBeInTheDocument()
+ expect(screen.getByText("Step three")).toBeInTheDocument()
+ })
+
+ it("should render nested lists with proper hierarchy", async () => {
+ const markdown = `Complex list:
+1. First level ordered
+ - Second level unordered
+ - Another second level
+ 1. Third level ordered
+ 2. Another third level
+2. Back to first level`
+
+ const { container } = render()
+
+ // Wait for the content to be processed
+ await screen.findByText(/Complex list/, { exact: false })
+
+ // Check nested structure
+ const olElements = container.querySelectorAll("ol")
+ const ulElements = container.querySelectorAll("ul")
+
+ expect(olElements.length).toBeGreaterThan(0)
+ expect(ulElements.length).toBeGreaterThan(0)
+
+ // Verify all text is rendered
+ expect(screen.getByText("First level ordered")).toBeInTheDocument()
+ expect(screen.getByText("Second level unordered")).toBeInTheDocument()
+ expect(screen.getByText("Third level ordered")).toBeInTheDocument()
+ expect(screen.getByText("Back to first level")).toBeInTheDocument()
+ })
})
diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx
index 01979060c3..5075643e6e 100644
--- a/webview-ui/src/components/settings/About.tsx
+++ b/webview-ui/src/components/settings/About.tsx
@@ -1,4 +1,4 @@
-import { HTMLAttributes, useState } from "react"
+import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import { Info, Download, Upload, TriangleAlert } from "lucide-react"
@@ -22,33 +22,9 @@ type AboutProps = HTMLAttributes & {
export const About = ({ telemetrySetting, setTelemetrySetting, className, ...props }: AboutProps) => {
const { t } = useAppTranslation()
- const [shouldThrowError, setShouldThrowError] = useState(false)
-
- // Function to trigger error for testing ErrorBoundary
- const triggerTestError = () => {
- setShouldThrowError(true)
- }
-
- // Named function to make it easier to identify in stack traces
- function throwTestError() {
- // Intentionally cause a type error by accessing a property on undefined
- const obj: any = undefined
- obj.nonExistentMethod()
- }
-
- // Test component that throws an error when shouldThrow is true
- const ErrorThrower = ({ shouldThrow = false }) => {
- if (shouldThrow) {
- // Use a named function to make it easier to identify in stack traces
- throwTestError()
- }
- return null
- }
return (
- {/* Test component that throws an error when shouldThrow is true */}
-
{t("settings:footer.settings.reset")}
-
- {/* Test button for ErrorBoundary - only visible in development */}
-
diff --git a/webview-ui/src/components/settings/providers/Bedrock.tsx b/webview-ui/src/components/settings/providers/Bedrock.tsx
index 1839298f9b..750f631856 100644
--- a/webview-ui/src/components/settings/providers/Bedrock.tsx
+++ b/webview-ui/src/components/settings/providers/Bedrock.tsx
@@ -1,6 +1,6 @@
import { useCallback, useState, useEffect } from "react"
import { Checkbox } from "vscrui"
-import { VSCodeTextField, VSCodeRadio, VSCodeRadioGroup } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { type ProviderSettings, type ModelInfo, BEDROCK_REGIONS } from "@roo-code/types"
@@ -37,19 +37,51 @@ export const Bedrock = ({ apiConfiguration, setApiConfigurationField, selectedMo
return (
<>
- (e.target as HTMLInputElement).value === "profile",
- )}>
- {t("settings:providers.awsCredentials")}
- {t("settings:providers.awsProfile")}
-
+
+
+
+
{t("settings:providers.apiKeyStorageNotice")}
- {apiConfiguration?.awsUseProfile ? (
+ {apiConfiguration?.awsUseApiKey ? (
+
+
+
+ ) : apiConfiguration?.awsUseProfile ? (
)}
+
{
setGoogleGeminiBaseUrlSelected(checked)
-
if (!checked) {
setApiConfigurationField("googleGeminiBaseUrl", "")
}
@@ -71,6 +72,27 @@ export const Gemini = ({ apiConfiguration, setApiConfigurationField }: GeminiPro
className="w-full mt-1"
/>
)}
+
+ setApiConfigurationField("enableUrlContext", checked)}>
+ {t("settings:providers.geminiParameters.urlContext.title")}
+
+
+ {t("settings:providers.geminiParameters.urlContext.description")}
+
+
+ setApiConfigurationField("enableGrounding", checked)}>
+ {t("settings:providers.geminiParameters.groundingSearch.title")}
+
+
+ {t("settings:providers.geminiParameters.groundingSearch.description")}
+
>
)
diff --git a/webview-ui/src/components/settings/providers/HuggingFace.tsx b/webview-ui/src/components/settings/providers/HuggingFace.tsx
index d4195492dd..8716739d80 100644
--- a/webview-ui/src/components/settings/providers/HuggingFace.tsx
+++ b/webview-ui/src/components/settings/providers/HuggingFace.tsx
@@ -9,30 +9,27 @@ import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
import { SearchableSelect, type SearchableSelectOption } from "@src/components/ui"
+import { cn } from "@src/lib/utils"
+import { formatPrice } from "@/utils/formatPrice"
import { inputEventTransform } from "../transforms"
type HuggingFaceModel = {
- _id: string
id: string
- inferenceProviderMapping: Array<{
+ object: string
+ created: number
+ owned_by: string
+ providers: Array<{
provider: string
- providerId: string
status: "live" | "staging" | "error"
- task: "conversational"
- }>
- trendingScore: number
- config: {
- architectures: string[]
- model_type: string
- tokenizer_config?: {
- chat_template?: string | Array<{ name: string; template: string }>
- model_max_length?: number
+ supports_tools?: boolean
+ supports_structured_output?: boolean
+ context_length?: number
+ pricing?: {
+ input: number
+ output: number
}
- }
- tags: string[]
- pipeline_tag: "text-generation" | "image-text-to-text"
- library_name?: string
+ }>
}
type HuggingFaceProps = {
@@ -81,10 +78,7 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
// Get current model and its providers
const currentModel = models.find((m) => m.id === apiConfiguration?.huggingFaceModelId)
- const availableProviders = useMemo(
- () => currentModel?.inferenceProviderMapping || [],
- [currentModel?.inferenceProviderMapping],
- )
+ const availableProviders = useMemo(() => currentModel?.providers || [], [currentModel?.providers])
// Set default provider when model changes
useEffect(() => {
@@ -140,6 +134,32 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
return nameMap[provider] || provider.charAt(0).toUpperCase() + provider.slice(1)
}
+ // Get current provider
+ const currentProvider = useMemo(() => {
+ if (!currentModel || !selectedProvider || selectedProvider === "auto") return null
+ return currentModel.providers.find((p) => p.provider === selectedProvider)
+ }, [currentModel, selectedProvider])
+
+ // Get model capabilities based on current provider
+ const modelCapabilities = useMemo(() => {
+ if (!currentModel) return null
+
+ // For now, assume text-only models since we don't have pipeline_tag in new API
+ // This could be enhanced by checking model name patterns or adding vision support detection
+ const supportsImages = false
+
+ // Use provider-specific capabilities if a specific provider is selected
+ const maxTokens =
+ currentProvider?.context_length || currentModel.providers.find((p) => p.context_length)?.context_length
+ const supportsTools = currentProvider?.supports_tools || currentModel.providers.some((p) => p.supports_tools)
+
+ return {
+ supportsImages,
+ maxTokens,
+ supportsTools,
+ }
+ }, [currentModel, currentProvider])
+
return (
<>
{t("settings:providers.huggingFaceApiKey")}
+
+ {t("settings:providers.apiKeyStorageNotice")}
+
+
+ {!apiConfiguration?.huggingFaceApiKey && (
+
+ {t("settings:providers.getHuggingFaceApiKey")}
+
+ )}
+
)}
-
- {t("settings:providers.apiKeyStorageNotice")}
-
-
- {!apiConfiguration?.huggingFaceApiKey && (
-
- {t("settings:providers.getHuggingFaceApiKey")}
-
+ {/* Model capabilities */}
+ {currentModel && modelCapabilities && (
+
+
+
+ {modelCapabilities.supportsImages
+ ? t("settings:modelInfo.supportsImages")
+ : t("settings:modelInfo.noImages")}
+
+ {modelCapabilities.maxTokens && (
+
+ {t("settings:modelInfo.maxOutput")}:{" "}
+ {modelCapabilities.maxTokens.toLocaleString()} tokens
+
+ )}
+ {currentProvider?.pricing && (
+ <>
+
+ {t("settings:modelInfo.inputPrice")}:{" "}
+ {formatPrice(currentProvider.pricing.input)} / 1M tokens
+
+
+ {t("settings:modelInfo.outputPrice")}:{" "}
+ {formatPrice(currentProvider.pricing.output)} / 1M tokens
+
+ >
+ )}
+
)}
>
)
diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx
index a2467b3c0b..caf7a173fe 100644
--- a/webview-ui/src/components/settings/providers/LiteLLM.tsx
+++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx
@@ -1,5 +1,5 @@
import { useCallback, useState, useEffect, useRef } from "react"
-import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
+import { VSCodeTextField, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { type ProviderSettings, type OrganizationAllowList, litellmDefaultModelId } from "@roo-code/types"
@@ -151,6 +151,29 @@ export const LiteLLM = ({
organizationAllowList={organizationAllowList}
errorMessage={modelValidationError}
/>
+
+ {/* Show prompt caching option if the selected model supports it */}
+ {(() => {
+ const selectedModelId = apiConfiguration.litellmModelId || litellmDefaultModelId
+ const selectedModel = routerModels?.litellm?.[selectedModelId]
+ if (selectedModel?.supportsPromptCache) {
+ return (
+
+
{
+ setApiConfigurationField("litellmUsePromptCache", e.target.checked)
+ }}>
+ {t("settings:providers.enablePromptCaching")}
+
+
+ {t("settings:providers.enablePromptCachingTitle")}
+
+
+ )
+ }
+ return null
+ })()}
>
)
}
diff --git a/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx
index b5bb16e975..b827024859 100644
--- a/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx
+++ b/webview-ui/src/components/settings/providers/__tests__/Bedrock.spec.tsx
@@ -61,11 +61,16 @@ vi.mock("@src/i18n/TranslationContext", () => ({
// Mock the UI components
vi.mock("@src/components/ui", () => ({
- Select: ({ children }: any) => {children}
,
- SelectContent: ({ children }: any) => {children}
,
- SelectItem: () => Item
,
- SelectTrigger: ({ children }: any) => {children}
,
- SelectValue: () => Value
,
+ Select: ({ children, value, onValueChange }: any) => (
+
+ ),
+ SelectContent: ({ children }: any) => <>{children}>,
+ SelectItem: ({ children, value }: any) => ,
+ SelectTrigger: ({ children }: any) => <>{children}>,
+ SelectValue: () => null,
+ StandardTooltip: ({ children }: any) => {children}
,
}))
// Mock the constants
@@ -424,5 +429,126 @@ describe("Bedrock Component", () => {
expect(screen.getByTestId("vpc-endpoint-input")).toBeInTheDocument()
expect(screen.getByTestId("vpc-endpoint-input")).toHaveValue("https://updated-endpoint.aws.com")
})
+
+ // Test Scenario 6: Authentication Method Selection Tests
+ describe("Authentication Method Selection", () => {
+ it("should display credentials option as selected when neither awsUseProfile nor awsUseApiKey is true", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: false,
+ awsUseApiKey: false,
+ }
+
+ render(
+ ,
+ )
+
+ // Find the first select element (authentication method)
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ expect(authSelect).toHaveValue("credentials")
+ })
+
+ it("should display profile option as selected when awsUseProfile is true", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: true,
+ awsUseApiKey: false,
+ }
+
+ render(
+ ,
+ )
+
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ expect(authSelect).toHaveValue("profile")
+ })
+
+ it("should display apikey option as selected when awsUseApiKey is true", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: false,
+ awsUseApiKey: true,
+ }
+
+ render(
+ ,
+ )
+
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ expect(authSelect).toHaveValue("apikey")
+ })
+
+ it("should call setApiConfigurationField correctly when switching to profile", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: false,
+ awsUseApiKey: false,
+ }
+
+ render(
+ ,
+ )
+
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ fireEvent.change(authSelect, { target: { value: "profile" } })
+
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseApiKey", false)
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseProfile", true)
+ })
+
+ it("should call setApiConfigurationField correctly when switching to apikey", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: false,
+ awsUseApiKey: false,
+ }
+
+ render(
+ ,
+ )
+
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ fireEvent.change(authSelect, { target: { value: "apikey" } })
+
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseApiKey", true)
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseProfile", false)
+ })
+
+ it("should call setApiConfigurationField correctly when switching to credentials", () => {
+ const apiConfiguration: Partial = {
+ awsUseProfile: true,
+ awsUseApiKey: false,
+ }
+
+ render(
+ ,
+ )
+
+ const selectInputs = screen.getAllByRole("combobox")
+ const authSelect = selectInputs[0] as HTMLSelectElement
+ fireEvent.change(authSelect, { target: { value: "credentials" } })
+
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseApiKey", false)
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("awsUseProfile", false)
+ })
+ })
})
})
diff --git a/webview-ui/src/components/settings/providers/__tests__/Gemini.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Gemini.spec.tsx
new file mode 100644
index 0000000000..cc3f4bd9f0
--- /dev/null
+++ b/webview-ui/src/components/settings/providers/__tests__/Gemini.spec.tsx
@@ -0,0 +1,130 @@
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { Gemini } from "../Gemini"
+import type { ProviderSettings } from "@roo-code/types"
+
+vi.mock("@vscode/webview-ui-toolkit/react", () => ({
+ VSCodeTextField: ({ children, value, onInput, type }: any) => (
+
+ {children}
+ onInput(e)} />
+
+ ),
+}))
+
+vi.mock("vscrui", () => ({
+ Checkbox: ({ children, checked, onChange, "data-testid": testId, _ }: any) => (
+
+ ),
+}))
+
+vi.mock("@src/i18n/TranslationContext", () => ({
+ useAppTranslation: () => ({ t: (key: string) => key }),
+}))
+
+vi.mock("@src/components/common/VSCodeButtonLink", () => ({
+ VSCodeButtonLink: ({ children, href }: any) => {children},
+}))
+
+describe("Gemini", () => {
+ const defaultApiConfiguration: ProviderSettings = {
+ geminiApiKey: "",
+ enableUrlContext: false,
+ enableGrounding: false,
+ }
+
+ const mockSetApiConfigurationField = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe("URL Context Checkbox", () => {
+ it("should render URL context checkbox unchecked by default", () => {
+ render(
+ ,
+ )
+
+ const urlContextCheckbox = screen.getByTestId("checkbox-url-context")
+ const checkbox = urlContextCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+ expect(checkbox.checked).toBe(false)
+ })
+
+ it("should render URL context checkbox checked when enableUrlContext is true", () => {
+ const apiConfiguration = { ...defaultApiConfiguration, enableUrlContext: true }
+ render(
+ ,
+ )
+
+ const urlContextCheckbox = screen.getByTestId("checkbox-url-context")
+ const checkbox = urlContextCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+ expect(checkbox.checked).toBe(true)
+ })
+
+ it("should call setApiConfigurationField with correct parameters when URL context checkbox is toggled", async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ )
+
+ const urlContextCheckbox = screen.getByTestId("checkbox-url-context")
+ const checkbox = urlContextCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+
+ await user.click(checkbox)
+
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableUrlContext", true)
+ })
+ })
+
+ describe("Grounding with Google Search Checkbox", () => {
+ it("should render grounding search checkbox unchecked by default", () => {
+ render(
+ ,
+ )
+
+ const groundingCheckbox = screen.getByTestId("checkbox-grounding-search")
+ const checkbox = groundingCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+ expect(checkbox.checked).toBe(false)
+ })
+
+ it("should render grounding search checkbox checked when enableGrounding is true", () => {
+ const apiConfiguration = { ...defaultApiConfiguration, enableGrounding: true }
+ render(
+ ,
+ )
+
+ const groundingCheckbox = screen.getByTestId("checkbox-grounding-search")
+ const checkbox = groundingCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+ expect(checkbox.checked).toBe(true)
+ })
+
+ it("should call setApiConfigurationField with correct parameters when grounding search checkbox is toggled", async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ )
+
+ const groundingCheckbox = screen.getByTestId("checkbox-grounding-search")
+ const checkbox = groundingCheckbox.querySelector("input[type='checkbox']") as HTMLInputElement
+
+ await user.click(checkbox)
+
+ expect(mockSetApiConfigurationField).toHaveBeenCalledWith("enableGrounding", true)
+ })
+ })
+})
diff --git a/webview-ui/src/components/settings/providers/__tests__/HuggingFace.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/HuggingFace.spec.tsx
index 3fd29e4c72..0256fe94d3 100644
--- a/webview-ui/src/components/settings/providers/__tests__/HuggingFace.spec.tsx
+++ b/webview-ui/src/components/settings/providers/__tests__/HuggingFace.spec.tsx
@@ -1,9 +1,8 @@
-import React from "react"
import { render, screen } from "@/utils/test-utils"
import { HuggingFace } from "../HuggingFace"
import { ProviderSettings } from "@roo-code/types"
-// Mock the VSCodeTextField component
+// Mock the VSCode components
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({
children,
@@ -32,6 +31,18 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({
)
},
+ VSCodeCheckbox: ({ children, checked, onChange, ...rest }: any) => (
+