From deabb1baa362e65403f069acc1ead7066553420e Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 28 Jul 2025 17:22:22 -0600 Subject: [PATCH 1/6] feat: add export/import buttons to mode selector popover (#6320) - Add export button for each mode (visible on hover) - Add import button in popover footer matching IconButton styling - Implement import dialog with project/global level selection - Add state management for export/import operations - Handle message responses for export/import results - Ensure perfect vertical alignment with existing buttons Fixes #6320 --- .../src/components/chat/ModeSelector.tsx | 349 ++++++++++++------ 1 file changed, 243 insertions(+), 106 deletions(-) diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index 93dd2f1f4f..e71469130c 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,11 @@ 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 trackModeSelectorOpened = React.useCallback(() => { // Track telemetry every time the mode selector is opened telemetryClient.capture(TelemetryEventName.MODE_SELECTOR_OPENED) @@ -153,6 +158,47 @@ export const ModeSelector = ({ } }, [open]) + // Handle export/import message responses + React.useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data + if (message.type === "exportModeResult") { + setIsExporting(null) + if (!message.success) { + console.error("Failed to export mode:", message.error) + } + } else 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) + }, []) + + // Handle export mode + const handleExportMode = React.useCallback((modeSlug: string) => { + setIsExporting(modeSlug) + vscode.postMessage({ + type: "exportMode", + slug: modeSlug, + }) + }, []) + + // Handle import mode + const handleImportMode = React.useCallback(() => { + const selectedLevel = (document.querySelector('input[name="importLevel"]:checked') as HTMLInputElement) + ?.value as "global" | "project" + setIsImporting(true) + vscode.postMessage({ + type: "importMode", + source: selectedLevel || "project", + }) + }, []) + // Determine if search should be shown const showSearch = !disableSearch && modes.length > SEARCH_THRESHOLD @@ -181,123 +227,214 @@ 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 && ( -
- + <> + + {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}

+
+ )} + + {/* 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 && } +
+
+ ))}
)}
- ) : ( -
-

{instructionText}

-
- )} - {/* Mode List */} -
- {filteredModes.length === 0 && searchValue ? ( -
- {t("chat:modeSelector.noResults")} -
- ) : ( -
- {filteredModes.map((mode) => ( -
handleSelect(mode.slug)} + {/* 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 - matching IconButton dimensions */} + +
- ))} + style={{ fontSize: 16.5 }}> + + +
- )} -
- {/* 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) - }} - /> + {/* 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")} +

+
+ + +
+
+ +
- - + )} + ) } From 02147e222c0cd03dfee969c56daf338090e70284 Mon Sep 17 00:00:00 2001 From: hannesrudolph Date: Mon, 28 Jul 2025 17:47:52 -0600 Subject: [PATCH 2/6] fix: address PR review feedback for mode selector export/import - Replace raw button with IconButton component for consistency - Add proper error handling with inline error messages - Add aria-label to export button for accessibility - Add unique IDs to radio inputs in import dialog - Add comprehensive test coverage for export/import functionality --- .../src/components/chat/ModeSelector.tsx | 78 ++-- .../ModeSelector.export-import.spec.tsx | 439 ++++++++++++++++++ 2 files changed, 488 insertions(+), 29 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ModeSelector.export-import.spec.tsx diff --git a/webview-ui/src/components/chat/ModeSelector.tsx b/webview-ui/src/components/chat/ModeSelector.tsx index e71469130c..81f2087acc 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, X, Upload, Download } from "lucide-react" +import { ChevronUp, Check, X, Upload } from "lucide-react" import { cn } from "@/lib/utils" import { useRooPortal } from "@/components/ui/hooks/useRooPortal" import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui" @@ -50,6 +50,8 @@ export const ModeSelector = ({ 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 trackModeSelectorOpened = React.useCallback(() => { // Track telemetry every time the mode selector is opened @@ -166,22 +168,31 @@ export const ModeSelector = ({ 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) - setShowImportDialog(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, @@ -193,6 +204,7 @@ export const ModeSelector = ({ const selectedLevel = (document.querySelector('input[name="importLevel"]:checked') as HTMLInputElement) ?.value as "global" | "project" setIsImporting(true) + setImportError(null) vscode.postMessage({ type: "importMode", source: selectedLevel || "project", @@ -309,6 +321,7 @@ export const ModeSelector = ({ handleExportMode(mode.slug) }} disabled={isExporting === mode.slug} + aria-label={t("prompts:exportMode.title")} title={t("prompts:exportMode.title")}> @@ -320,6 +333,13 @@ export const ModeSelector = ({ )}
+ {/* Export error message */} + {exportError && ( +
+ {exportError} +
+ )} + {/* Bottom bar with buttons on left and title on right */}
@@ -349,28 +369,14 @@ export const ModeSelector = ({ setOpen(false) }} /> - {/* Import button - matching IconButton dimensions */} - - - + {/* Import button - using IconButton for consistency */} + setShowImportDialog(true)} + disabled={isImporting} + isLoading={isImporting} + />
{/* Info icon and title on the right - only show info icon when search bar is visible */} @@ -398,11 +404,12 @@ export const ModeSelector = ({ {t("prompts:importMode.selectLevel")}

-
-