Skip to content
Closed
7 changes: 5 additions & 2 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface ExportResult {

interface ImportResult {
success: boolean
slug?: string
error?: string
}

Expand Down Expand Up @@ -411,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"
Expand Down Expand Up @@ -457,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
}
}

Expand Down Expand Up @@ -989,7 +991,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 }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Multi-mode import behavior: this returns only the first imported mode's slug (customModes[0]). If a YAML contains multiple modes, the UI will switch to the first only. Either (a) validate single-mode imports and surface a clear error, or (b) return a list of slugs (or the last processed slug) and adapt the UI to a defined behavior.

Copy link
Author

@heyseth heyseth Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What behavior do we want from Roo when the user imports a modes.yaml file containing multiple modes? Personally I think auto-selecting the first mode from the list is fine (this is the current behavior in my implementation).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thoughts? @hannesrudolph

} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error("Failed to import mode with rules", { error: errorMessage })
Expand Down
3 changes: 2 additions & 1 deletion src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion webview-ui/src/components/modes/ModesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ 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])

// 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)
Expand Down Expand Up @@ -460,7 +477,23 @@ const ModesView = ({ onDone }: ModesViewProps) => {
setIsImporting(false)
setShowImportDialog(false)

if (!message.success) {
if (message.success) {
// 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)
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)
switchModeRef.current?.(slug)
}
}
} else {
// Only log error if it's not a cancellation
if (message.error !== "cancelled") {
console.error("Failed to import mode:", message.error)
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ExtensionStateContext.Provider value={{ ...baseState, customModes: [importedMode] } as any}>
<ModesView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)

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(
<ExtensionStateContext.Provider value={{ ...baseState } as any}>
<ModesView onDone={vitest.fn()} />
</ExtensionStateContext.Provider>,
)

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")
})
})