diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 93dd2f1f4f..ecefa3c49a 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -1,8 +1,8 @@ import React from "react" -import { ChevronUp, Check, X } from "lucide-react" +import { ChevronUp, Check, X, Upload, Download } from "lucide-react" import { cn } from "@/lib/utils" import { useRooPortal } from "@/components/ui/hooks/useRooPortal" -import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui" import { IconButton } from "./IconButton" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" @@ -46,6 +46,14 @@ export const ModeSelector = ({ const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() const { t } = useAppTranslation() + // Export/Import state + const [isExporting, setIsExporting] = React.useState(null) + const [isImporting, setIsImporting] = React.useState(false) + const [showImportDialog, setShowImportDialog] = React.useState(false) + const [exportError, setExportError] = React.useState(null) + const [importError, setImportError] = React.useState(null) + const [selectedImportLevel, setSelectedImportLevel] = React.useState<"project" | "global">("project") + const trackModeSelectorOpened = React.useCallback(() => { // Track telemetry every time the mode selector is opened telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) @@ -153,6 +161,58 @@ export const ModeSelector = ({ } }, [open]) + // Handle export/import message responses + React.useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data + // Only handle messages that are specifically for mode export/import + if (!message || typeof message !== "object") return + + if (message.type === "exportModeResult") { + setIsExporting(null) + if (!message.success) { + console.error("Failed to export mode:", message.error) + setExportError(message.error || t("prompts:exportMode.error")) + // Clear error after 5 seconds + setTimeout(() => setExportError(null), 5000) + } else { + setExportError(null) + } + } else if (message.type === "importModeResult") { + setIsImporting(false) + if (!message.success && message.error !== "cancelled") { + console.error("Failed to import mode:", message.error) + setImportError(message.error || t("prompts:importMode.error")) + } else { + setImportError(null) + setShowImportDialog(false) + } + } + } + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [t]) + + // Handle export mode + const handleExportMode = React.useCallback((modeSlug: string) => { + setIsExporting(modeSlug) + setExportError(null) + vscode.postMessage({ + type: "exportMode", + slug: modeSlug, + }) + }, []) + + // Handle import mode + const handleImportMode = React.useCallback(() => { + setIsImporting(true) + setImportError(null) + vscode.postMessage({ + type: "importMode", + source: selectedImportLevel, + }) + }, [selectedImportLevel]) + // Determine if search should be shown const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD @@ -181,123 +241,227 @@ export const ModeSelector = ({ ) return ( - - {title ? {trigger} : trigger} - - -
- {/* 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 && ( -
- -
- )} -
- ) : ( -
-

{instructionText}

-
- )} + <> + + {title ? {trigger} : trigger} - {/* Mode List */} -
- {filteredModes.length === 0 && searchValue ? ( -
- {t("chat:modeSelector.noResults")} + +
+ {/* 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 && ( +
+ +
+ )}
) : ( -
- {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} -
+
+

{instructionText}

+
+ )} + + {/* Mode List */} +
+ {filteredModes.length === 0 && searchValue ? ( +
+ {t("chat:modeSelector.noResults")} +
+ ) : ( +
+ {filteredModes.map((mode) => ( +
+
handleSelect(mode.slug)}> +
{mode.name}
+ {mode.description && ( +
+ {mode.description} +
+ )} +
+
+ {/* Export button - show on hover or when exporting */} + + {mode.slug === value && } +
- {mode.slug === value && } -
- ))} + ))} +
+ )} +
+ + {/* Export error message */} + {exportError && ( +
+ {exportError}
)} -
- {/* Bottom bar with buttons on left and title on right */} -
-
- { - window.postMessage( - { - type: "action", - action: "marketplaceButtonClicked", - values: { marketplaceTab: "mode" }, - }, - "*", - ) - setOpen(false) - }} - /> - { - vscode.postMessage({ - type: "switchTab", - tab: "modes", - }) - setOpen(false) - }} - /> + {/* Bottom bar with buttons on left and title on right */} +
+
+ { + window.postMessage( + { + type: "action", + action: "marketplaceButtonClicked", + values: { marketplaceTab: "mode" }, + }, + "*", + ) + setOpen(false) + }} + /> + { + vscode.postMessage({ + type: "switchTab", + tab: "modes", + }) + setOpen(false) + }} + /> + {/* Import button - using Button with Download icon to match ModesView */} + +
+ + {/* Info icon and title on the right - only show info icon when search bar is visible */} +
+ {showSearch && ( + + + + )} +

+ {t("chat:modeSelector.title")} +

+
+
+ + - {/* Info icon and title on the right - only show info icon when search bar is visible */} -
- {showSearch && ( - - - - )} -

- {t("chat:modeSelector.title")} -

+ {/* Import Mode Dialog */} + {showImportDialog && ( +
+
+

{t("prompts:modes.importMode")}

+

+ {t("prompts:importMode.selectLevel")} +

+
+ + +
+ {/* Import error message */} + {importError &&
{importError}
} +
+ +
- - + )} + ) } diff --git a/webview-ui/src/components/chat/__tests__/ModeSelector.export-import.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.export-import.spec.tsx new file mode 100644 index 0000000000..f5d54d1d76 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ModeSelector.export-import.spec.tsx @@ -0,0 +1,440 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" +import { describe, test, expect, vi, beforeEach } from "vitest" +import ModeSelector from "../ModeSelector" +import { Mode } from "@roo/modes" +import { ModeConfig } from "@roo-code/types" + +// Mock the dependencies +const mockPostMessage = vi.fn() +vi.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: (message: any) => mockPostMessage(message), + }, +})) + +vi.mock("@/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + hasOpenedModeSelector: false, + setHasOpenedModeSelector: vi.fn(), + }), +})) + +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, _options?: any) => { + if (key === "prompts:exportMode.title") return "Export Mode" + if (key === "prompts:modes.importMode") return "Import Mode" + if (key === "prompts:importMode.selectLevel") return "Select import level" + if (key === "prompts:importMode.project.label") return "Project" + if (key === "prompts:importMode.project.description") return "Import to project" + if (key === "prompts:importMode.global.label") return "Global" + if (key === "prompts:importMode.global.description") return "Import globally" + if (key === "prompts:createModeDialog.buttons.cancel") return "Cancel" + if (key === "prompts:importMode.import") return "Import" + if (key === "prompts:importMode.importing") return "Importing..." + if (key === "prompts:exportMode.error") return "Export failed" + if (key === "prompts:importMode.error") return "Import failed" + if (key === "chat:modeSelector.marketplace") return "Marketplace" + if (key === "chat:modeSelector.settings") return "Settings" + if (key === "chat:modeSelector.searchPlaceholder") return "Search modes" + if (key === "chat:modeSelector.noResults") return "No results" + if (key === "chat:modeSelector.description") return "Select a mode" + if (key === "chat:modeSelector.title") return "Modes" + return key + }, + }), +})) + +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 Export/Import", () => { + beforeEach(() => { + vi.clearAllMocks() + // Set up mock modes + mockModes = [ + { + slug: "code", + name: "Code", + description: "Write code", + roleDefinition: "You are a coding assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect", + description: "Design systems", + roleDefinition: "You are a system architect", + groups: ["read"], + }, + ] + }) + + describe("Export Functionality", () => { + test("export button is hidden by default and shows on hover", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find the mode item + const modeItems = screen.getAllByTestId("mode-selector-item") + const codeItem = modeItems[0] + + // Export button should have opacity-0 class initially + const exportButton = codeItem.querySelector('button[aria-label="Export Mode"]') + expect(exportButton).toHaveClass("opacity-0") + + // Hover over the mode item + fireEvent.mouseEnter(codeItem) + + // Export button should now have opacity-100 class + expect(exportButton).toHaveClass("group-hover:opacity-100") + }) + + test("clicking export button sends exportMode message", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find and click the export button for the first mode + const modeItems = screen.getAllByTestId("mode-selector-item") + const exportButton = modeItems[0].querySelector('button[aria-label="Export Mode"]') as HTMLButtonElement + fireEvent.click(exportButton) + + // Verify the message was sent + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "exportMode", + slug: "code", + }) + }) + + test("export button shows loading state while exporting", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find and click the export button + const modeItems = screen.getAllByTestId("mode-selector-item") + const exportButton = modeItems[0].querySelector('button[aria-label="Export Mode"]') as HTMLButtonElement + fireEvent.click(exportButton) + + // Button should be disabled + expect(exportButton).toBeDisabled() + }) + + test("handles export success", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Click export + const modeItems = screen.getAllByTestId("mode-selector-item") + const exportButton = modeItems[0].querySelector('button[aria-label="Export Mode"]') as HTMLButtonElement + fireEvent.click(exportButton) + + // Simulate success response + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "exportModeResult", + success: true, + }, + }), + ) + + // Button should be enabled again + await waitFor(() => { + expect(exportButton).not.toBeDisabled() + }) + }) + + test("handles export error", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Click export + const modeItems = screen.getAllByTestId("mode-selector-item") + const exportButton = modeItems[0].querySelector('button[aria-label="Export Mode"]') as HTMLButtonElement + fireEvent.click(exportButton) + + // Simulate error response + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "exportModeResult", + success: false, + error: "Failed to save file", + }, + }), + ) + + // Error message should be displayed + await waitFor(() => { + expect(screen.getByText("Failed to save file")).toBeInTheDocument() + }) + + // Button should be enabled again + expect(exportButton).not.toBeDisabled() + }) + }) + + describe("Import Functionality", () => { + test("import button is visible in the footer", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Import button should be visible + const importButton = screen.getByRole("button", { name: "Import Mode" }) + expect(importButton).toBeInTheDocument() + // Check for the Download icon (SVG) inside the button + const icon = importButton.querySelector("svg") + expect(icon).toBeInTheDocument() + }) + + test("clicking import button opens import dialog", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Click import button + const importButton = screen.getByRole("button", { name: "Import Mode" }) + fireEvent.click(importButton) + + // Import dialog should be visible + expect(screen.getByText("Select import level")).toBeInTheDocument() + // Check for the text content instead of label association + expect(screen.getByText("Project")).toBeInTheDocument() + expect(screen.getByText("Global")).toBeInTheDocument() + }) + + test("import dialog has unique IDs for radio inputs", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Check for unique IDs + const projectRadio = document.getElementById("import-level-project") as HTMLInputElement + const globalRadio = document.getElementById("import-level-global") as HTMLInputElement + + expect(projectRadio).toBeInTheDocument() + expect(globalRadio).toBeInTheDocument() + expect(projectRadio.id).toBe("import-level-project") + expect(globalRadio.id).toBe("import-level-global") + }) + + test("project level is selected by default", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Project should be checked by default + const projectRadio = document.getElementById("import-level-project") as HTMLInputElement + const globalRadio = document.getElementById("import-level-global") as HTMLInputElement + + expect(projectRadio).toBeInTheDocument() + expect(globalRadio).toBeInTheDocument() + expect(projectRadio.checked).toBe(true) + expect(globalRadio.checked).toBe(false) + }) + + test("clicking import sends importMode message with selected level", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Select global + const globalRadio = document.getElementById("import-level-global") as HTMLInputElement + expect(globalRadio).toBeInTheDocument() + fireEvent.click(globalRadio) + + // Click import + const importButton = screen.getByText("Import") + fireEvent.click(importButton) + + // Verify the message was sent + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "importMode", + source: "global", + }) + }) + + test("import button shows loading state while importing", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Click import + const importButton = screen.getByText("Import") + fireEvent.click(importButton) + + // Button should show importing text + expect(screen.getByText("Importing...")).toBeInTheDocument() + }) + + test("handles import success", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Click import + const importButton = screen.getByText("Import") + fireEvent.click(importButton) + + // Simulate success response + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "importModeResult", + success: true, + }, + }), + ) + + // Dialog should be closed + await waitFor(() => { + expect(screen.queryByText("Select import level")).not.toBeInTheDocument() + }) + }) + + test("handles import error", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Click import + const importButton = screen.getByText("Import") + fireEvent.click(importButton) + + // Simulate error response + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "importModeResult", + success: false, + error: "Invalid file format", + }, + }), + ) + + // Error message should be displayed + await waitFor(() => { + expect(screen.getByText("Invalid file format")).toBeInTheDocument() + }) + + // Dialog should still be open + expect(screen.getByText("Select import level")).toBeInTheDocument() + }) + + test("handles cancelled import", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Click import + const importButton = screen.getByText("Import") + fireEvent.click(importButton) + + // Simulate cancelled response + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "importModeResult", + success: false, + error: "cancelled", + }, + }), + ) + + // Dialog should be closed and no error shown + await waitFor(() => { + expect(screen.queryByText("Select import level")).not.toBeInTheDocument() + }) + expect(screen.queryByText("cancelled")).not.toBeInTheDocument() + }) + + test("cancel button closes import dialog", async () => { + render() + + // Open the popover and import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByRole("button", { name: "Import Mode" })) + + // Click cancel + const cancelButton = screen.getByText("Cancel") + fireEvent.click(cancelButton) + + // Dialog should be closed + expect(screen.queryByText("Select import level")).not.toBeInTheDocument() + }) + }) + + describe("Accessibility", () => { + test("export button has proper aria-label", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find export button + const modeItems = screen.getAllByTestId("mode-selector-item") + const exportButton = modeItems[0].querySelector('button[aria-label="Export Mode"]') + + expect(exportButton).toHaveAttribute("aria-label", "Export Mode") + }) + + test("import button has proper structure", async () => { + render() + + // Open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Import button should have proper structure + const importButton = screen.getByRole("button", { name: "Import Mode" }) + expect(importButton).toHaveAttribute("title", "Import Mode") + // Check for the Download icon (SVG) inside the button + expect(importButton.querySelector("svg")).toBeInTheDocument() + }) + }) +})