Skip to content

Commit 0a70720

Browse files
committed
feat: automatically switch to imported mode after successful import (#6491)
- Add importedModes field to ImportResult interface to track imported mode slugs - Modify importModeWithRules to collect and return imported mode slugs - Update webview message handler to automatically switch to first imported mode - Add proper validation and error handling for mode switching - Update ModesView to handle automatic mode switching in UI - Add comprehensive tests for all components Fixes #6491
1 parent 3803c29 commit 0a70720

File tree

6 files changed

+271
-8
lines changed

6 files changed

+271
-8
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ interface ExportResult {
4141
interface ImportResult {
4242
success: boolean
4343
error?: string
44+
importedModes?: string[] // Slugs of successfully imported modes
4445
}
4546

4647
export class CustomModesManager {
@@ -786,7 +787,7 @@ export class CustomModesManager {
786787
// This excludes the rules-{slug} folder from the path
787788
const relativePath = path.relative(modeRulesDir, filePath)
788789
// Normalize path to use forward slashes for cross-platform compatibility
789-
const normalizedRelativePath = relativePath.replace(/\\/g, '/')
790+
const normalizedRelativePath = relativePath.replace(/\\/g, "/")
790791
rulesFiles.push({ relativePath: normalizedRelativePath, content: content.trim() })
791792
}
792793
}
@@ -949,6 +950,9 @@ export class CustomModesManager {
949950
}
950951
}
951952

953+
// Track successfully imported mode slugs
954+
const importedModes: string[] = []
955+
952956
// Process each mode in the import
953957
for (const importMode of importData.customModes) {
954958
const { rulesFiles, ...modeConfig } = importMode
@@ -980,12 +984,16 @@ export class CustomModesManager {
980984

981985
// Import rules files (this also handles cleanup of existing rules folders)
982986
await this.importRulesFiles(importMode, rulesFiles || [], source)
987+
988+
// Record the imported mode slug
989+
importedModes.push(importMode.slug)
983990
}
984991

985992
// Refresh the modes after import
986993
await this.refreshMergedState()
987994

988-
return { success: true }
995+
// Return success with imported mode slugs
996+
return { success: true, importedModes }
989997
} catch (error) {
990998
const errorMessage = error instanceof Error ? error.message : String(error)
991999
logger.error("Failed to import mode with rules", { error: errorMessage })

src/core/config/__tests__/CustomModesManager.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1235,6 +1235,47 @@ describe("CustomModesManager", () => {
12351235
const newRulePath = Object.keys(writtenFiles).find((p) => p.includes("new-rule.md"))
12361236
expect(writtenFiles[newRulePath!]).toBe("New rule content")
12371237
})
1238+
1239+
it("should return imported mode slugs on successful import", async () => {
1240+
const importYaml = yaml.stringify({
1241+
customModes: [
1242+
{
1243+
slug: "mode-one",
1244+
name: "Mode One",
1245+
roleDefinition: "Role One",
1246+
groups: ["read"],
1247+
},
1248+
{
1249+
slug: "mode-two",
1250+
name: "Mode Two",
1251+
roleDefinition: "Role Two",
1252+
groups: ["edit"],
1253+
},
1254+
],
1255+
})
1256+
1257+
let roomodesContent: any = null
1258+
;(fs.readFile as Mock).mockImplementation(async (path: string) => {
1259+
if (path === mockSettingsPath) {
1260+
return yaml.stringify({ customModes: [] })
1261+
}
1262+
if (path === mockRoomodes && roomodesContent) {
1263+
return yaml.stringify(roomodesContent)
1264+
}
1265+
throw new Error("File not found")
1266+
})
1267+
;(fs.writeFile as Mock).mockImplementation(async (path: string, content: string) => {
1268+
if (path === mockRoomodes) {
1269+
roomodesContent = yaml.parse(content)
1270+
}
1271+
return Promise.resolve()
1272+
})
1273+
1274+
const result = await manager.importModeWithRules(importYaml)
1275+
1276+
expect(result.success).toBe(true)
1277+
expect(result.importedModes).toEqual(["mode-one", "mode-two"])
1278+
})
12381279
})
12391280
})
12401281

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,129 @@ describe("webviewMessageHandler - requestRouterModels", () => {
266266
type: "requestRouterModels",
267267
})
268268

269+
describe("webviewMessageHandler - importMode", () => {
270+
beforeEach(() => {
271+
vi.clearAllMocks()
272+
// Add handleModeSwitch to the mock
273+
mockClineProvider.handleModeSwitch = vi.fn()
274+
// Mock customModesManager.importModeWithRules
275+
mockClineProvider.customModesManager.importModeWithRules = vi.fn()
276+
})
277+
278+
it("should switch to imported mode on successful import", async () => {
279+
const mockImportResult = {
280+
success: true,
281+
importedModes: ["imported-mode"],
282+
}
283+
284+
vi.mocked(mockClineProvider.customModesManager.importModeWithRules).mockResolvedValue(mockImportResult)
285+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
286+
{
287+
slug: "imported-mode",
288+
name: "Imported Mode",
289+
roleDefinition: "Imported Role",
290+
groups: ["read"],
291+
},
292+
])
293+
294+
// Mock vscode.window.showOpenDialog
295+
const vscode = await import("vscode")
296+
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([{ fsPath: "/path/to/mode.yaml" }] as any)
297+
298+
// Mock fs.readFile
299+
const fs = await import("fs/promises")
300+
vi.mocked(fs.readFile).mockResolvedValue("yaml content")
301+
302+
await webviewMessageHandler(mockClineProvider, {
303+
type: "importMode",
304+
source: "project",
305+
})
306+
307+
// Should switch to the imported mode
308+
expect(mockClineProvider.handleModeSwitch).toHaveBeenCalledWith("imported-mode")
309+
310+
// Should send success message with imported modes
311+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
312+
type: "importModeResult",
313+
success: true,
314+
payload: { importedModes: ["imported-mode"] },
315+
})
316+
})
317+
318+
it("should switch to first mode when multiple modes are imported", async () => {
319+
const mockImportResult = {
320+
success: true,
321+
importedModes: ["mode-one", "mode-two"],
322+
}
323+
324+
vi.mocked(mockClineProvider.customModesManager.importModeWithRules).mockResolvedValue(mockImportResult)
325+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([
326+
{
327+
slug: "mode-one",
328+
name: "Mode One",
329+
roleDefinition: "Role One",
330+
groups: ["read"],
331+
},
332+
{
333+
slug: "mode-two",
334+
name: "Mode Two",
335+
roleDefinition: "Role Two",
336+
groups: ["edit"],
337+
},
338+
])
339+
340+
// Mock vscode.window.showOpenDialog
341+
const vscode = await import("vscode")
342+
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([{ fsPath: "/path/to/mode.yaml" }] as any)
343+
344+
// Mock fs.readFile
345+
const fs = await import("fs/promises")
346+
vi.mocked(fs.readFile).mockResolvedValue("yaml content")
347+
348+
await webviewMessageHandler(mockClineProvider, {
349+
type: "importMode",
350+
source: "project",
351+
})
352+
353+
// Should switch to the first mode only
354+
expect(mockClineProvider.handleModeSwitch).toHaveBeenCalledWith("mode-one")
355+
expect(mockClineProvider.handleModeSwitch).toHaveBeenCalledTimes(1)
356+
})
357+
358+
it("should handle invalid mode slug gracefully", async () => {
359+
const mockImportResult = {
360+
success: true,
361+
importedModes: ["invalid-mode-that-does-not-exist"],
362+
}
363+
364+
vi.mocked(mockClineProvider.customModesManager.importModeWithRules).mockResolvedValue(mockImportResult)
365+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([])
366+
367+
// Mock vscode.window.showOpenDialog
368+
const vscode = await import("vscode")
369+
vi.mocked(vscode.window.showOpenDialog).mockResolvedValue([{ fsPath: "/path/to/mode.yaml" }] as any)
370+
371+
// Mock fs.readFile
372+
const fs = await import("fs/promises")
373+
vi.mocked(fs.readFile).mockResolvedValue("yaml content")
374+
375+
await webviewMessageHandler(mockClineProvider, {
376+
type: "importMode",
377+
source: "project",
378+
})
379+
380+
// Should not call handleModeSwitch with invalid mode
381+
expect(mockClineProvider.handleModeSwitch).not.toHaveBeenCalled()
382+
383+
// Should still send success message
384+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
385+
type: "importModeResult",
386+
success: true,
387+
payload: { importedModes: ["invalid-mode-that-does-not-exist"] },
388+
})
389+
})
390+
})
391+
269392
// Verify successful providers are included
270393
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
271394
type: "routerModels",

src/core/webview/webviewMessageHandler.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type ProviderSettings,
1212
type GlobalState,
1313
type ClineMessage,
14+
type ModeConfig,
1415
TelemetryEventName,
1516
} from "@roo-code/types"
1617
import { CloudService } from "@roo-code/cloud"
@@ -1876,12 +1877,47 @@ export const webviewMessageHandler = async (
18761877
// Update state after importing
18771878
const customModes = await provider.customModesManager.getCustomModes()
18781879
await updateGlobalState("customModes", customModes)
1879-
await provider.postStateToWebview()
18801880

1881-
// Send success message to webview
1881+
// Switch to the first imported mode if any were imported
1882+
if (result.importedModes && result.importedModes.length > 0) {
1883+
try {
1884+
// Switch to the first imported mode
1885+
const modeToSwitch = result.importedModes[0]
1886+
1887+
// Validate the mode exists before switching
1888+
const allModes = await provider.customModesManager.getCustomModes()
1889+
const validMode = allModes.find((m: ModeConfig) => m.slug === modeToSwitch)
1890+
1891+
if (validMode) {
1892+
// Validate that the mode slug is a valid string before type assertion
1893+
if (typeof modeToSwitch === "string" && modeToSwitch.length > 0) {
1894+
await provider.handleModeSwitch(modeToSwitch as Mode)
1895+
// Update the webview with the new mode
1896+
await provider.postStateToWebview()
1897+
1898+
// Track telemetry for automatic mode switch after import
1899+
TelemetryService.instance.captureEvent(TelemetryEventName.MODE_SWITCH, {
1900+
taskId: provider.getCurrentCline()?.taskId || "no-task",
1901+
newMode: modeToSwitch,
1902+
trigger: "auto-import",
1903+
})
1904+
} else {
1905+
provider.log(`Invalid mode slug for switching: ${modeToSwitch}`)
1906+
}
1907+
}
1908+
} catch (error) {
1909+
provider.log(
1910+
`Failed to switch to imported mode: ${error instanceof Error ? error.message : String(error)}`,
1911+
)
1912+
// Continue with success response - import succeeded even if switch failed
1913+
}
1914+
}
1915+
1916+
// Send success message to webview with imported modes info
18821917
provider.postMessageToWebview({
18831918
type: "importModeResult",
18841919
success: true,
1920+
payload: { importedModes: result.importedModes },
18851921
})
18861922

18871923
// Show success message

webview-ui/src/components/modes/ModesView.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -460,10 +460,24 @@ const ModesView = ({ onDone }: ModesViewProps) => {
460460
setIsImporting(false)
461461
setShowImportDialog(false)
462462

463-
if (!message.success) {
463+
// Extract data from message payload
464+
const { success, error } = message
465+
const importedModes = message.payload?.importedModes
466+
467+
if (success && importedModes && importedModes.length > 0) {
468+
// Find the imported mode configuration
469+
const importedModeSlug = importedModes[0]
470+
const importedMode =
471+
findModeBySlug(importedModeSlug, customModes) || modes.find((m) => m.slug === importedModeSlug)
472+
473+
if (importedMode) {
474+
// Switch to the imported mode
475+
handleModeSwitch(importedMode)
476+
}
477+
} else if (!success) {
464478
// Only log error if it's not a cancellation
465-
if (message.error !== "cancelled") {
466-
console.error("Failed to import mode:", message.error)
479+
if (error !== "cancelled") {
480+
console.error("Failed to import mode:", error)
467481
}
468482
}
469483
} else if (message.type === "checkRulesDirectoryResult") {
@@ -487,7 +501,8 @@ const ModesView = ({ onDone }: ModesViewProps) => {
487501

488502
window.addEventListener("message", handler)
489503
return () => window.removeEventListener("message", handler)
490-
}, []) // Empty dependency array - only register once
504+
// Re-register handler when dependencies change to ensure it has access to latest values
505+
}, [customModes, findModeBySlug, handleModeSwitch, modes])
491506

492507
const handleAgentReset = (
493508
modeSlug: string,

webview-ui/src/components/modes/__tests__/ModesView.spec.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,44 @@ describe("PromptsView", () => {
264264
// Verify popover remains closed
265265
expect(selectTrigger).toHaveAttribute("aria-expanded", "false")
266266
})
267+
268+
it("should switch to imported mode on successful import", async () => {
269+
const mockVscode = vscode as any
270+
mockVscode.postMessage = vitest.fn()
271+
272+
// Render with custom modes
273+
const customModes = [
274+
{
275+
slug: "new-mode",
276+
name: "New Mode",
277+
roleDefinition: "New mode role",
278+
groups: ["read"],
279+
},
280+
]
281+
282+
const { rerender: _rerender } = renderPromptsView({
283+
mode: "code",
284+
customModes: customModes,
285+
})
286+
287+
// Simulate import success message
288+
await waitFor(() => {
289+
window.postMessage(
290+
{
291+
type: "importModeResult",
292+
success: true,
293+
payload: { importedModes: ["new-mode"] },
294+
},
295+
"*",
296+
)
297+
})
298+
299+
// Wait for the mode switch message to be sent
300+
await waitFor(() => {
301+
expect(mockVscode.postMessage).toHaveBeenCalledWith({
302+
type: "mode",
303+
text: "new-mode",
304+
})
305+
})
306+
})
267307
})

0 commit comments

Comments
 (0)