From a2792d1e9f364859cb1d7e46d9904b60afeae44f Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 13:59:11 -0700 Subject: [PATCH 1/8] feat: auto-switch to imported mode after successful import Fixes [#8239](https://github.com/RooCodeInc/Roo-Code/issues/8239) ## Summary Automatically switches to the imported mode after a successful mode import, providing immediate feedback and eliminating the need for manual mode selection. ## Changes ### Backend (CustomModesManager.ts) - Modified `importModeWithRules()` to return the imported mode's slug in the result - Returns `{ success: true, slug: importData.customModes[0]?.slug }` on success - Enables the UI to know which mode was imported for automatic switching ### Message Handler (webviewMessageHandler.ts) - Updated `importMode` case to handle the new slug return value - Passes the slug to the webview via `importModeResult` message - Maintains backward compatibility with existing error handling ### UI (ModesView.tsx) - Added auto-switch logic in `importModeResult` message handler - Attempts to find imported mode in fresh customModes list - Falls back to direct slug-based switch if mode not yet in list - Updates visual mode state for immediate UI feedback - Only switches on success with a valid slug provided ### Tests (ModesView.import-switch.spec.tsx) - Added new test suite for import auto-switch behavior - Tests successful switch when slug is provided - Tests no-switch behavior on import failure or missing slug - Verifies both backend message and UI state updates ## Testing - New test coverage added for auto-switch scenarios - Existing import/export functionality remains unchanged - Works with both global and project-level imports --- src/core/config/CustomModesManager.ts | 4 +- src/core/webview/webviewMessageHandler.ts | 3 +- webview-ui/src/components/modes/ModesView.tsx | 27 +++++- .../ModesView.import-switch.spec.tsx | 94 +++++++++++++++++++ 4 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a9a2e6a6b55..3ce96a5fb9b 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -40,6 +40,7 @@ interface ExportResult { interface ImportResult { success: boolean + slug?: string error?: string } @@ -989,7 +990,8 @@ export class CustomModesManager { // Refresh the modes after import await this.refreshMergedState() - return { success: true } + // Return the imported mode's slug so the UI can activate it + return { success: true, slug: importData.customModes[0]?.slug } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) logger.error("Failed to import mode with rules", { error: errorMessage }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af5f9925c35..8c13df47583 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2222,10 +2222,11 @@ export const webviewMessageHandler = async ( await updateGlobalState("customModes", customModes) await provider.postStateToWebview() - // Send success message to webview + // Send success message to webview, include the imported slug so UI can switch provider.postMessageToWebview({ type: "importModeResult", success: true, + slug: result.slug, }) // Show success message diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index c50996585fe..760935368f9 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -186,6 +186,17 @@ const ModesView = ({ onDone }: ModesViewProps) => { [visualMode, switchMode], ) + // Keep latest handleModeSwitch and customModes available inside window message handler + const handleModeSwitchRef = useRef(handleModeSwitch) + useEffect(() => { + handleModeSwitchRef.current = handleModeSwitch + }, [handleModeSwitch]) + + const customModesRef = useRef(customModes) + useEffect(() => { + customModesRef.current = customModes + }, [customModes]) + // Handler for popover open state change const onOpenChange = useCallback((open: boolean) => { setOpen(open) @@ -460,7 +471,21 @@ const ModesView = ({ onDone }: ModesViewProps) => { setIsImporting(false) setShowImportDialog(false) - if (!message.success) { + if (message.success) { + const slug = (message as any).slug as string | undefined + if (slug) { + // Try switching using the freshest mode list available + const all = getAllModes(customModesRef.current) + const importedMode = all.find((m) => m.slug === slug) + if (importedMode) { + handleModeSwitchRef.current(importedMode) + } else { + // Fallback: switch by slug to keep backend in sync and update visual selection + setVisualMode(slug) + switchMode(slug) + } + } + } else { // Only log error if it's not a cancellation if (message.error !== "cancelled") { console.error("Failed to import mode:", message.error) diff --git a/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx b/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx new file mode 100644 index 00000000000..c31f8480ed9 --- /dev/null +++ b/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from "@/utils/test-utils" +import ModesView from "../ModesView" +import { ExtensionStateContext } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +vitest.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vitest.fn(), + }, +})) + +const baseState = { + customModePrompts: {}, + listApiConfigMeta: [], + enhancementApiConfigId: "", + setEnhancementApiConfigId: vitest.fn(), + mode: "code", + customModes: [], + customSupportPrompts: [], + currentApiConfigName: "", + customInstructions: "", + setCustomInstructions: vitest.fn(), +} + +describe("ModesView - auto switch after import", () => { + beforeEach(() => { + vitest.clearAllMocks() + }) + + it("switches to imported mode when import succeeds and slug is provided", async () => { + const importedMode = { + slug: "imported-mode", + name: "Imported Mode", + roleDefinition: "Role", + groups: ["read"] as const, + source: "global" as const, + } + + render( + + + , + ) + + const trigger = screen.getByTestId("mode-select-trigger") + expect(trigger).toHaveTextContent("Code") + + // Simulate extension sending successful import result with slug + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "importModeResult", success: true, slug: "imported-mode" }, + }), + ) + + // Backend switch message sent + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "imported-mode" }) + }) + + // UI reflects new mode selection + await waitFor(() => { + expect(trigger).toHaveTextContent("Imported Mode") + }) + }) + + it("does not switch when import fails or slug missing", async () => { + render( + + + , + ) + + const trigger = screen.getByTestId("mode-select-trigger") + expect(trigger).toHaveTextContent("Code") + + // Import failure + window.dispatchEvent( + new MessageEvent("message", { data: { type: "importModeResult", success: false, error: "x" } }), + ) + + await waitFor(() => { + expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "mode", text: expect.any(String) }) + }) + expect(trigger).toHaveTextContent("Code") + + // Success but no slug provided + window.dispatchEvent(new MessageEvent("message", { data: { type: "importModeResult", success: true } })) + + await waitFor(() => { + expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "mode", text: expect.any(String) }) + }) + expect(trigger).toHaveTextContent("Code") + }) +}) From 2bdaff028c030ccd933b0118d725009bc37f93fd Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 14:26:07 -0700 Subject: [PATCH 2/8] fix(modes): resolve react-hooks/exhaustive-deps in window message listener Use a stable ref to hold the latest switchMode and avoid capturing it in the window "message" listener closure: Add a switchModeRef updated via useEffect; call switchModeRef.current in the importModeResult fallback instead of switchMode Mirrors existing handleModeSwitchRef/customModesRef pattern to keep handler stable while accessing fresh values Prevents re-registration churn and removes the ESLint warning with no runtime behavior change File touched: webview-ui/src/components/modes/ModesView.tsx Why: The effect that registers the window event listener intentionally has an empty dependency array; directly referencing switchMode violates react-hooks/exhaustive-deps. Using a ref decouples the effect from function identity changes while preserving up-to-date behavior. --- webview-ui/src/components/modes/ModesView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 760935368f9..d043bcff377 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -197,6 +197,12 @@ const ModesView = ({ onDone }: ModesViewProps) => { customModesRef.current = customModes }, [customModes]) + // Keep latest switchMode available inside window message handler + const switchModeRef = useRef(switchMode) + useEffect(() => { + switchModeRef.current = switchMode + }, [switchMode]) + // Handler for popover open state change const onOpenChange = useCallback((open: boolean) => { setOpen(open) @@ -482,7 +488,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { } else { // Fallback: switch by slug to keep backend in sync and update visual selection setVisualMode(slug) - switchMode(slug) + switchModeRef.current?.(slug) } } } else { From 61d746e2a9115f0ef96b381a6ceda913f1ff406a Mon Sep 17 00:00:00 2001 From: Seth Miller Date: Sun, 5 Oct 2025 14:50:51 -0700 Subject: [PATCH 3/8] Update webview-ui/src/components/modes/ModesView.tsx Type safety for slug Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- webview-ui/src/components/modes/ModesView.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index d043bcff377..f333a33fc78 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -478,7 +478,9 @@ const ModesView = ({ onDone }: ModesViewProps) => { setShowImportDialog(false) if (message.success) { - const slug = (message as any).slug as string | undefined +// Type safe access to slug +type ImportModeResult = { type: 'importModeResult'; success: boolean; slug?: string; error?: string } +const { slug } = message as ImportModeResult if (slug) { // Try switching using the freshest mode list available const all = getAllModes(customModesRef.current) From 90ab20bcf55d1b3f6023e769d41d1f0e1cea6994 Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 15:29:10 -0700 Subject: [PATCH 4/8] fix: propagate updateCustomMode errors to prevent false-positive import success updateCustomMode() was catching and swallowing all errors, causing importModeWithRules() to return success even when mode persistence failed. This led to auto-switch attempting to activate modes that never persisted. --- src/core/config/CustomModesManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index 3ce96a5fb9b..a243a9236be 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -412,7 +412,7 @@ export class CustomModesManager { const errorMessage = `Invalid mode configuration: ${errorMessages}` logger.error("Mode validation failed", { slug, errors: validationResult.error.errors }) vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage })) - return + throw new Error(errorMessage) } const isProjectMode = config.source === "project" @@ -458,6 +458,7 @@ export class CustomModesManager { const errorMessage = error instanceof Error ? error.message : String(error) logger.error("Failed to update custom mode", { slug, error: errorMessage }) vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage })) + throw error } } From 34134f9bff3e48aaadc0c78a0fee2b0d465f0a40 Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 15:44:28 -0700 Subject: [PATCH 5/8] refactor(modes): hoist ImportModeResult type to module scope Moves type declaration out of event handler to prevent redeclaration on every message event. --- webview-ui/src/components/modes/ModesView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index f333a33fc78..b21d0f6392b 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -55,6 +55,8 @@ const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group) type ModeSource = "global" | "project" +type ImportModeResult = { type: 'importModeResult'; success: boolean; slug?: string; error?: string } + type ModesViewProps = { onDone: () => void } @@ -478,9 +480,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { setShowImportDialog(false) if (message.success) { -// Type safe access to slug -type ImportModeResult = { type: 'importModeResult'; success: boolean; slug?: string; error?: string } -const { slug } = message as ImportModeResult + const { slug } = message as ImportModeResult if (slug) { // Try switching using the freshest mode list available const all = getAllModes(customModesRef.current) From 6fe074d17c2e9297b60625716360edc25f9f0108 Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 15:58:31 -0700 Subject: [PATCH 6/8] fix(modes): wrap updateCustomMode in try-catch to prevent unhandled rejections while allowing importModeWithRules to detect persistence failures --- src/core/webview/webviewMessageHandler.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 8c13df47583..142520dbabf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1981,6 +1981,7 @@ export const webviewMessageHandler = async ( break case "updateCustomMode": if (message.modeConfig) { + try { // Check if this is a new mode or an update to an existing mode const existingModes = await provider.customModesManager.getCustomModes() const isNewMode = !existingModes.some((mode) => mode.slug === message.modeConfig?.slug) @@ -2016,6 +2017,10 @@ export const webviewMessageHandler = async ( } } } + } catch (error) { + // Error already shown to user by updateCustomMode + // Just prevent unhandled rejection and skip state updates + } } break case "deleteCustomMode": From 3e3d3249ff8e9ab01e7cd82d62f8999a9a8b6c9e Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 16:28:17 -0700 Subject: [PATCH 7/8] fix(modes): address parser consistency, UI desync, and test coverage issues - Use parseYamlSafely() in importModeWithRules for consistent YAML parsing across the codebase, ensuring BOM stripping and character cleaning - Sync visualMode with context.mode to prevent UI desync when modes are switched programmatically from outside the component - Add test coverage for fallback branch when imported mode slug is not yet present in customModes state Fixes parser inconsistency (P2), visualMode desync risk (P3), and missing test coverage (P3) identified in code review. --- src/core/config/CustomModesManager.ts | 2 +- webview-ui/src/components/modes/ModesView.tsx | 5 ++++ .../ModesView.import-switch.spec.tsx | 24 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index a243a9236be..d65dbdadc22 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -932,7 +932,7 @@ export class CustomModesManager { // Parse the YAML content with proper type validation let importData: ImportData try { - const parsed = yaml.parse(yamlContent) + const parsed = this.parseYamlSafely(yamlContent, '') // Validate the structure if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) { diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index b21d0f6392b..39d9237c55f 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -205,6 +205,11 @@ const ModesView = ({ onDone }: ModesViewProps) => { switchModeRef.current = switchMode }, [switchMode]) + // Sync visualMode with backend mode changes to prevent desync + useEffect(() => { + setVisualMode(mode) + }, [mode]) + // Handler for popover open state change const onOpenChange = useCallback((open: boolean) => { setOpen(open) diff --git a/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx b/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx index c31f8480ed9..8be38e4cce6 100644 --- a/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx +++ b/webview-ui/src/components/modes/__tests__/ModesView.import-switch.spec.tsx @@ -91,4 +91,28 @@ describe("ModesView - auto switch after import", () => { }) expect(trigger).toHaveTextContent("Code") }) + + it("uses fallback branch when imported slug not yet present in customModes", async () => { + // Render with empty customModes - imported mode hasn't been added to state yet + render( + + + , + ) + + const trigger = screen.getByTestId("mode-select-trigger") + expect(trigger).toHaveTextContent("Code") + + // Simulate successful import for a slug not yet in customModes (timing race condition) + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "importModeResult", success: true, slug: "not-yet-loaded-mode" }, + }), + ) + + // Fallback branch should send backend switch message + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "not-yet-loaded-mode" }) + }) + }) }) From 5183aec434bcd8a422b38f76e97bf8dddbdaf3ce Mon Sep 17 00:00:00 2001 From: heyseth Date: Sun, 5 Oct 2025 16:37:00 -0700 Subject: [PATCH 8/8] revert: replace parseYamlSafely with yaml.parse --- src/core/config/CustomModesManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/config/CustomModesManager.ts b/src/core/config/CustomModesManager.ts index d65dbdadc22..a243a9236be 100644 --- a/src/core/config/CustomModesManager.ts +++ b/src/core/config/CustomModesManager.ts @@ -932,7 +932,7 @@ export class CustomModesManager { // Parse the YAML content with proper type validation let importData: ImportData try { - const parsed = this.parseYamlSafely(yamlContent, '') + const parsed = yaml.parse(yamlContent) // Validate the structure if (!parsed?.customModes || !Array.isArray(parsed.customModes) || parsed.customModes.length === 0) {