diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 3c212f11cd..f3a565e93a 100644 --- a/webview-ui/src/components/chat/ModeSelector.tsx +++ b/webview-ui/src/components/chat/ModeSelector.tsx @@ -2,8 +2,7 @@ import React from "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, Button } from "@/components/ui" -import { IconButton } from "./IconButton" +import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui" import { vscode } from "@/utils/vscode" import { useExtensionState } from "@/context/ExtensionStateContext" import { useAppTranslation } from "@/i18n/TranslationContext" @@ -12,6 +11,9 @@ import { ModeConfig, CustomModePrompts } from "@roo-code/types" import { telemetryClient } from "@/utils/TelemetryClient" import { TelemetryEventName } from "@roo-code/types" import { Fzf } from "fzf" +import { ImportModeDialog } from "@/components/common/ImportModeDialog" +import { ModeSelectorFooter } from "./ModeSelectorFooter" +import { useModeSelectorExportImport } from "./useModeSelectorExportImport" // Minimum number of modes required to show search functionality const SEARCH_THRESHOLD = 6 @@ -45,8 +47,17 @@ export const ModeSelector = ({ const portalContainer = useRooPortal("roo-portal") const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState() const { t } = useAppTranslation() - const [showImportDialog, setShowImportDialog] = React.useState(false) - const [isImporting, setIsImporting] = React.useState(false) + const { + showImportDialog, + isImporting, + exportError, + importError, + handleExport, + handleImport, + openImportDialog, + closeImportDialog, + clearErrors, + } = useModeSelectorExportImport() const trackModeSelectorOpened = React.useCallback(() => { // Track telemetry every time the mode selector is opened @@ -155,23 +166,6 @@ export const ModeSelector = ({ } }, [open]) - // Handle import/export result messages - React.useEffect(() => { - const handler = (event: MessageEvent) => { - const message = event.data - if (message.type === "importModeResult") { - setIsImporting(false) - setShowImportDialog(false) - if (!message.success && message.error !== "cancelled") { - console.error("Failed to import mode:", message.error) - } - } - } - - window.addEventListener("message", handler) - return () => window.removeEventListener("message", handler) - }, []) - // Determine if search should be shown const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD @@ -273,131 +267,44 @@ export const ModeSelector = ({ {/* Bottom bar with buttons on left and title on right */} -
-
- { - window.postMessage( - { - type: "action", - action: "marketplaceButtonClicked", - values: { marketplaceTab: "mode" }, - }, - "*", - ) - setOpen(false) - }} - /> - { - if (value) { - vscode.postMessage({ - type: "exportMode", - slug: value, - }) - } - setOpen(false) - }} - /> - { - setShowImportDialog(true) - setOpen(false) - }} - /> - { - 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")} -

-
-
+ handleExport(value)} + onImport={openImportDialog} + onClose={() => setOpen(false)} + /> {/* Import Mode Dialog */} - {showImportDialog && ( -
-
-

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

-

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

-
- - -
-
- - + + + {/* Error notifications */} + {(exportError || importError) && ( +
+
+ +
+
+ {exportError ? t("prompts:exportMode.errorTitle") : t("prompts:importMode.errorTitle")} +
+
+ {exportError || importError} +
+
)} diff --git a/webview-ui/src/components/chat/ModeSelectorFooter.tsx b/webview-ui/src/components/chat/ModeSelectorFooter.tsx new file mode 100644 index 0000000000..381217e10f --- /dev/null +++ b/webview-ui/src/components/chat/ModeSelectorFooter.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { IconButton } from "./IconButton" +import { StandardTooltip } from "@/components/ui" +import { vscode } from "@/utils/vscode" +import { useAppTranslation } from "@/i18n/TranslationContext" + +interface ModeSelectorFooterProps { + selectedMode: string | null + showSearch: boolean + instructionText: string + onExport: () => void + onImport: () => void + onClose: () => void +} + +export const ModeSelectorFooter: React.FC = ({ + selectedMode, + showSearch, + instructionText, + onExport, + onImport, + onClose, +}) => { + const { t } = useAppTranslation() + + const handleMarketplaceClick = () => { + window.postMessage( + { + type: "action", + action: "marketplaceButtonClicked", + values: { marketplaceTab: "mode" }, + }, + "*", + ) + onClose() + } + + const handleExportClick = () => { + if (selectedMode) { + onExport() + } + onClose() + } + + const handleImportClick = () => { + onImport() + onClose() + } + + const handleSettingsClick = () => { + vscode.postMessage({ + type: "switchTab", + tab: "modes", + }) + onClose() + } + + return ( +
+
+ + + + +
+ + {/* 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/__tests__/ModeSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx index a829168893..945dd4e80b 100644 --- a/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ModeSelector.spec.tsx @@ -1,9 +1,10 @@ import React from "react" -import { render, screen, fireEvent } from "@/utils/test-utils" -import { describe, test, expect, vi } from "vitest" +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" +import { vscode } from "@/utils/vscode" // Mock the dependencies vi.mock("@/utils/vscode", () => ({ @@ -12,6 +13,9 @@ vi.mock("@/utils/vscode", () => ({ }, })) +// Spy on window.postMessage +const windowPostMessageSpy = vi.spyOn(window, "postMessage") + vi.mock("@/context/ExtensionStateContext", () => ({ useExtensionState: () => ({ hasOpenedModeSelector: false, @@ -47,6 +51,12 @@ vi.mock("@roo/modes", async () => { }) describe("ModeSelector", () => { + beforeEach(() => { + vi.clearAllMocks() + // Reset mock modes + mockModes = [] + }) + test("shows custom description from customModePrompts", () => { const customModePrompts = { code: { @@ -199,4 +209,259 @@ describe("ModeSelector", () => { const infoIcon = document.querySelector(".codicon-info") expect(infoIcon).toBeInTheDocument() }) + + describe("Export functionality", () => { + test("export button triggers export message", () => { + // Set up mock to return a few modes + mockModes = Array.from({ length: 3 }, (_, 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")) + + // Find and click the export button + const exportButton = screen.getByLabelText("prompts:exportMode.title") + fireEvent.click(exportButton) + + // Should have sent export message + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "exportMode", + slug: "mode-0", + }) + }) + + test("export error is displayed when export fails", async () => { + render() + + // Simulate export error message + const errorEvent = new MessageEvent("message", { + data: { + type: "exportModeResult", + success: false, + error: "Failed to export mode", + }, + }) + window.dispatchEvent(errorEvent) + + // Error notification should be displayed + await waitFor(() => { + expect(screen.getByText("prompts:exportMode.errorTitle")).toBeInTheDocument() + expect(screen.getByText("Failed to export mode")).toBeInTheDocument() + }) + }) + }) + + describe("Import functionality", () => { + test("import button opens import dialog", () => { + render() + + // Click to open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find and click the import button + const importButton = screen.getByLabelText("prompts:modes.importMode") + fireEvent.click(importButton) + + // Import dialog should be displayed + expect(screen.getByText("prompts:modes.importMode")).toBeInTheDocument() + expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument() + }) + + test("import dialog allows selection between project and global", () => { + render() + + // Open popover and click import + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + const importButton = screen.getByLabelText("prompts:modes.importMode") + fireEvent.click(importButton) + + // Check radio buttons are present + const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/) + const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/) + + expect(projectRadio).toBeInTheDocument() + expect(globalRadio).toBeInTheDocument() + expect(projectRadio).toBeChecked() + expect(globalRadio).not.toBeChecked() + }) + + test("import dialog cancel button closes dialog", () => { + render() + + // Open import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByLabelText("prompts:modes.importMode")) + + // Click cancel + const cancelButton = screen.getByText("prompts:createModeDialog.buttons.cancel") + fireEvent.click(cancelButton) + + // Dialog should be closed + expect(screen.queryByText("prompts:importMode.selectLevel")).not.toBeInTheDocument() + }) + + test("import dialog import button triggers import message", () => { + render() + + // Open import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByLabelText("prompts:modes.importMode")) + + // Select global option + const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/) + fireEvent.click(globalRadio) + + // Click import + const importButton = screen.getByText("prompts:importMode.import") + fireEvent.click(importButton) + + // Should have sent import message + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "importMode", + source: "global", + }) + }) + + test("import error is displayed when import fails", async () => { + render() + + // Simulate import error message + const errorEvent = new MessageEvent("message", { + data: { + type: "importModeResult", + success: false, + error: "Failed to import mode", + }, + }) + window.dispatchEvent(errorEvent) + + // Error notification should be displayed + await waitFor(() => { + expect(screen.getByText("prompts:importMode.errorTitle")).toBeInTheDocument() + expect(screen.getByText("Failed to import mode")).toBeInTheDocument() + }) + }) + + test("import dialog closes on successful import", async () => { + render() + + // Open import dialog + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + fireEvent.click(screen.getByLabelText("prompts:modes.importMode")) + + // Dialog should be open + expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument() + + // Simulate successful import message + const successEvent = new MessageEvent("message", { + data: { + type: "importModeResult", + success: true, + }, + }) + window.dispatchEvent(successEvent) + + // Dialog should be closed + await waitFor(() => { + expect(screen.queryByText("prompts:importMode.selectLevel")).not.toBeInTheDocument() + }) + }) + + test("cancelled import does not show error", async () => { + render() + + // Simulate cancelled import message + const cancelEvent = new MessageEvent("message", { + data: { + type: "importModeResult", + success: false, + error: "cancelled", + }, + }) + window.dispatchEvent(cancelEvent) + + // No error notification should be displayed + await waitFor(() => { + expect(screen.queryByText("prompts:importMode.errorTitle")).not.toBeInTheDocument() + }) + }) + }) + + describe("Bottom bar buttons", () => { + test("marketplace button sends correct message", () => { + render() + + // Click to open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find and click the marketplace button + const marketplaceButton = screen.getByLabelText("chat:modeSelector.marketplace") + fireEvent.click(marketplaceButton) + + // Should have sent marketplace message + expect(windowPostMessageSpy).toHaveBeenCalledWith( + { + type: "action", + action: "marketplaceButtonClicked", + values: { marketplaceTab: "mode" }, + }, + "*", + ) + }) + + test("settings button sends correct message", () => { + render() + + // Click to open the popover + fireEvent.click(screen.getByTestId("mode-selector-trigger")) + + // Find and click the settings button + const settingsButton = screen.getByLabelText("chat:modeSelector.settings") + fireEvent.click(settingsButton) + + // Should have sent switch tab message + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "switchTab", + tab: "modes", + }) + }) + }) + + describe("Error notification behavior", () => { + test("error notification can be closed manually", async () => { + render() + + // Simulate export error + const errorEvent = new MessageEvent("message", { + data: { + type: "exportModeResult", + success: false, + error: "Test error", + }, + }) + window.dispatchEvent(errorEvent) + + // Error should be displayed + await waitFor(() => { + expect(screen.getByText("Test error")).toBeInTheDocument() + }) + + // Click close button - find the button with X icon inside the error notification + const errorNotification = screen.getByText("Test error").closest("div")?.parentElement?.parentElement + const closeButton = errorNotification?.querySelector("button:last-child") + if (closeButton) { + fireEvent.click(closeButton) + } + + // Error should be gone + expect(screen.queryByText("Test error")).not.toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/components/chat/useModeSelectorExportImport.ts b/webview-ui/src/components/chat/useModeSelectorExportImport.ts new file mode 100644 index 0000000000..ae348f964c --- /dev/null +++ b/webview-ui/src/components/chat/useModeSelectorExportImport.ts @@ -0,0 +1,78 @@ +import React from "react" +import { vscode } from "@/utils/vscode" +import { useAppTranslation } from "@/i18n/TranslationContext" + +export const useModeSelectorExportImport = () => { + const { t } = useAppTranslation() + const [showImportDialog, setShowImportDialog] = React.useState(false) + const [isImporting, setIsImporting] = React.useState(false) + const [exportError, setExportError] = React.useState(null) + const [importError, setImportError] = React.useState(null) + + // Handle import/export result messages + React.useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data + if (message.type === "importModeResult") { + setIsImporting(false) + setShowImportDialog(false) + if (!message.success && message.error !== "cancelled") { + setImportError(message.error || t("prompts:importMode.error")) + // Clear error after 5 seconds + setTimeout(() => setImportError(null), 5000) + } + } else if (message.type === "exportModeResult") { + if (!message.success) { + setExportError(message.error || t("prompts:exportMode.error")) + // Clear error after 5 seconds + setTimeout(() => setExportError(null), 5000) + } + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [t]) + + const handleExport = (modeSlug: string) => { + setExportError(null) + vscode.postMessage({ + type: "exportMode", + slug: modeSlug, + }) + } + + const handleImport = (source: "global" | "project") => { + setIsImporting(true) + vscode.postMessage({ + type: "importMode", + source, + }) + } + + const openImportDialog = () => { + setImportError(null) + setShowImportDialog(true) + } + + const closeImportDialog = () => { + setShowImportDialog(false) + } + + const clearErrors = () => { + setExportError(null) + setImportError(null) + } + + return { + showImportDialog, + isImporting, + exportError, + importError, + handleExport, + handleImport, + openImportDialog, + closeImportDialog, + clearErrors, + } +} diff --git a/webview-ui/src/components/common/ImportModeDialog.tsx b/webview-ui/src/components/common/ImportModeDialog.tsx new file mode 100644 index 0000000000..192a63b547 --- /dev/null +++ b/webview-ui/src/components/common/ImportModeDialog.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { Button } from "@/components/ui" +import { useAppTranslation } from "@/i18n/TranslationContext" + +interface ImportModeDialogProps { + isOpen: boolean + onClose: () => void + onImport: (source: "global" | "project") => void + isImporting?: boolean +} + +export const ImportModeDialog: React.FC = ({ + isOpen, + onClose, + onImport, + isImporting = false, +}) => { + const { t } = useAppTranslation() + + if (!isOpen) return null + + const handleImport = () => { + const selectedLevel = (document.querySelector('input[name="importLevel"]:checked') as HTMLInputElement) + ?.value as "global" | "project" + onImport(selectedLevel || "project") + } + + return ( +
+
+

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

+

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

+
+ + +
+
+ + +
+
+
+ ) +} diff --git a/webview-ui/src/components/common/__tests__/ImportModeDialog.spec.tsx b/webview-ui/src/components/common/__tests__/ImportModeDialog.spec.tsx new file mode 100644 index 0000000000..267ea2ebd0 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/ImportModeDialog.spec.tsx @@ -0,0 +1,111 @@ +import React from "react" +import { render, screen, fireEvent } from "@/utils/test-utils" +import { describe, test, expect, vi } from "vitest" +import { ImportModeDialog } from "../ImportModeDialog" + +// Mock the translation hook +vi.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe("ImportModeDialog", () => { + test("renders nothing when not open", () => { + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + test("renders dialog when open", () => { + render() + + expect(screen.getByText("prompts:modes.importMode")).toBeInTheDocument() + expect(screen.getByText("prompts:importMode.selectLevel")).toBeInTheDocument() + }) + + test("project option is selected by default", () => { + render() + + const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/) + const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/) + + expect(projectRadio).toBeChecked() + expect(globalRadio).not.toBeChecked() + }) + + test("can switch between project and global options", () => { + render() + + const projectRadio = screen.getByLabelText(/prompts:importMode.project.label/) + const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/) + + // Initially project is selected + expect(projectRadio).toBeChecked() + expect(globalRadio).not.toBeChecked() + + // Click global + fireEvent.click(globalRadio) + + // Now global should be selected + expect(projectRadio).not.toBeChecked() + expect(globalRadio).toBeChecked() + }) + + test("cancel button calls onClose", () => { + const onClose = vi.fn() + render() + + const cancelButton = screen.getByText("prompts:createModeDialog.buttons.cancel") + fireEvent.click(cancelButton) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + test("import button calls onImport with selected source", () => { + const onImport = vi.fn() + render() + + // Select global + const globalRadio = screen.getByLabelText(/prompts:importMode.global.label/) + fireEvent.click(globalRadio) + + // Click import + const importButton = screen.getByText("prompts:importMode.import") + fireEvent.click(importButton) + + expect(onImport).toHaveBeenCalledWith("global") + }) + + test("import button calls onImport with project by default", () => { + const onImport = vi.fn() + render() + + // Click import without changing selection + const importButton = screen.getByText("prompts:importMode.import") + fireEvent.click(importButton) + + expect(onImport).toHaveBeenCalledWith("project") + }) + + test("import button is disabled when isImporting is true", () => { + render() + + const importButton = screen.getByText("prompts:importMode.importing") + expect(importButton).toBeDisabled() + }) + + test("shows importing text when isImporting is true", () => { + render() + + expect(screen.getByText("prompts:importMode.importing")).toBeInTheDocument() + expect(screen.queryByText("prompts:importMode.import")).not.toBeInTheDocument() + }) + + test("displays project and global descriptions", () => { + render() + + expect(screen.getByText("prompts:importMode.project.description")).toBeInTheDocument() + expect(screen.getByText("prompts:importMode.global.description")).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 170d03b0e4..fc3e2959e7 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -48,6 +48,7 @@ import { StandardTooltip, } from "@src/components/ui" import { DeleteModeDialog } from "@src/components/modes/DeleteModeDialog" +import { ImportModeDialog } from "@src/components/common/ImportModeDialog" // Get all available groups that should show in prompts view const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) => !TOOL_GROUPS[group].alwaysAvailable) @@ -96,6 +97,8 @@ const ModesView = ({ onDone }: ModesViewProps) => { const [isExporting, setIsExporting] = useState(false) const [isImporting, setIsImporting] = useState(false) const [showImportDialog, setShowImportDialog] = useState(false) + const [exportError, setExportError] = useState(null) + const [importError, setImportError] = useState(null) const [hasRulesToExport, setHasRulesToExport] = useState>({}) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [modeToDelete, setModeToDelete] = useState<{ @@ -449,17 +452,20 @@ const ModesView = ({ onDone }: ModesViewProps) => { setIsExporting(false) if (!message.success) { - // Show error message - 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 if (message.type === "importModeResult") { setIsImporting(false) setShowImportDialog(false) if (!message.success) { - // Only log error if it's not a cancellation + // Only show error if it's not a cancellation if (message.error !== "cancelled") { - console.error("Failed to import mode:", message.error) + setImportError(message.error || t("prompts:importMode.error")) + // Clear error after 5 seconds + setTimeout(() => setImportError(null), 5000) } } } else if (message.type === "checkRulesDirectoryResult") { @@ -483,7 +489,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { window.addEventListener("message", handler) return () => window.removeEventListener("message", handler) - }, []) // Empty dependency array - only register once + }, [t]) // Add t to dependency array const handleAgentReset = ( modeSlug: string, @@ -1203,6 +1209,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { const currentMode = getCurrentMode() if (currentMode?.slug && !isExporting) { setIsExporting(true) + setExportError(null) vscode.postMessage({ type: "exportMode", slug: currentMode.slug, @@ -1219,7 +1226,10 @@ const ModesView = ({ onDone }: ModesViewProps) => { {/* Import button - always visible */} - + setShowImportDialog(false)} + onImport={(source) => { + setIsImporting(true) + vscode.postMessage({ + type: "importMode", + source, + }) + }} + isImporting={isImporting} + /> + + {/* Error notifications */} + {(exportError || importError) && ( +
+
+ +
+
+ {exportError ? t("prompts:exportMode.errorTitle") : t("prompts:importMode.errorTitle")} +
+
+ {exportError || importError} +
+
)}