Skip to content

Commit b17760b

Browse files
daniel-lxsroomote[bot]heysethroomote
authored
feat: auto-switch to imported mode with architect fallback (#9003)
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> Co-authored-by: Seth Miller <[email protected]> Co-authored-by: heyseth <[email protected]> Co-authored-by: Roo Code <[email protected]>
1 parent 37ac53e commit b17760b

File tree

4 files changed

+240
-35
lines changed

4 files changed

+240
-35
lines changed

src/core/config/CustomModesManager.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface ExportResult {
4040

4141
interface ImportResult {
4242
success: boolean
43+
slug?: string
4344
error?: string
4445
}
4546

@@ -411,7 +412,7 @@ export class CustomModesManager {
411412
const errorMessage = `Invalid mode configuration: ${errorMessages}`
412413
logger.error("Mode validation failed", { slug, errors: validationResult.error.errors })
413414
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
414-
return
415+
throw new Error(errorMessage)
415416
}
416417

417418
const isProjectMode = config.source === "project"
@@ -457,6 +458,7 @@ export class CustomModesManager {
457458
const errorMessage = error instanceof Error ? error.message : String(error)
458459
logger.error("Failed to update custom mode", { slug, error: errorMessage })
459460
vscode.window.showErrorMessage(t("common:customModes.errors.updateFailed", { error: errorMessage }))
461+
throw error
460462
}
461463
}
462464

@@ -989,7 +991,8 @@ export class CustomModesManager {
989991
// Refresh the modes after import
990992
await this.refreshMergedState()
991993

992-
return { success: true }
994+
// Return the imported mode's slug so the UI can activate it
995+
return { success: true, slug: importData.customModes[0]?.slug }
993996
} catch (error) {
994997
const errorMessage = error instanceof Error ? error.message : String(error)
995998
logger.error("Failed to import mode with rules", { error: errorMessage })

src/core/webview/webviewMessageHandler.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2044,40 +2044,45 @@ export const webviewMessageHandler = async (
20442044
break
20452045
case "updateCustomMode":
20462046
if (message.modeConfig) {
2047-
// Check if this is a new mode or an update to an existing mode
2048-
const existingModes = await provider.customModesManager.getCustomModes()
2049-
const isNewMode = !existingModes.some((mode) => mode.slug === message.modeConfig?.slug)
2050-
2051-
await provider.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig)
2052-
// Update state after saving the mode
2053-
const customModes = await provider.customModesManager.getCustomModes()
2054-
await updateGlobalState("customModes", customModes)
2055-
await updateGlobalState("mode", message.modeConfig.slug)
2056-
await provider.postStateToWebview()
2057-
2058-
// Track telemetry for custom mode creation or update
2059-
if (TelemetryService.hasInstance()) {
2060-
if (isNewMode) {
2061-
// This is a new custom mode
2062-
TelemetryService.instance.captureCustomModeCreated(
2063-
message.modeConfig.slug,
2064-
message.modeConfig.name,
2065-
)
2066-
} else {
2067-
// Determine which setting was changed by comparing objects
2068-
const existingMode = existingModes.find((mode) => mode.slug === message.modeConfig?.slug)
2069-
const changedSettings = existingMode
2070-
? Object.keys(message.modeConfig).filter(
2071-
(key) =>
2072-
JSON.stringify((existingMode as Record<string, unknown>)[key]) !==
2073-
JSON.stringify((message.modeConfig as Record<string, unknown>)[key]),
2074-
)
2075-
: []
2047+
try {
2048+
// Check if this is a new mode or an update to an existing mode
2049+
const existingModes = await provider.customModesManager.getCustomModes()
2050+
const isNewMode = !existingModes.some((mode) => mode.slug === message.modeConfig?.slug)
2051+
2052+
await provider.customModesManager.updateCustomMode(message.modeConfig.slug, message.modeConfig)
2053+
// Update state after saving the mode
2054+
const customModes = await provider.customModesManager.getCustomModes()
2055+
await updateGlobalState("customModes", customModes)
2056+
await updateGlobalState("mode", message.modeConfig.slug)
2057+
await provider.postStateToWebview()
20762058

2077-
if (changedSettings.length > 0) {
2078-
TelemetryService.instance.captureModeSettingChanged(changedSettings[0])
2059+
// Track telemetry for custom mode creation or update
2060+
if (TelemetryService.hasInstance()) {
2061+
if (isNewMode) {
2062+
// This is a new custom mode
2063+
TelemetryService.instance.captureCustomModeCreated(
2064+
message.modeConfig.slug,
2065+
message.modeConfig.name,
2066+
)
2067+
} else {
2068+
// Determine which setting was changed by comparing objects
2069+
const existingMode = existingModes.find((mode) => mode.slug === message.modeConfig?.slug)
2070+
const changedSettings = existingMode
2071+
? Object.keys(message.modeConfig).filter(
2072+
(key) =>
2073+
JSON.stringify((existingMode as Record<string, unknown>)[key]) !==
2074+
JSON.stringify((message.modeConfig as Record<string, unknown>)[key]),
2075+
)
2076+
: []
2077+
2078+
if (changedSettings.length > 0) {
2079+
TelemetryService.instance.captureModeSettingChanged(changedSettings[0])
2080+
}
20792081
}
20802082
}
2083+
} catch (error) {
2084+
// Error already shown to user by updateCustomMode
2085+
// Just prevent unhandled rejection and skip state updates
20812086
}
20822087
}
20832088
break
@@ -2285,10 +2290,11 @@ export const webviewMessageHandler = async (
22852290
await updateGlobalState("customModes", customModes)
22862291
await provider.postStateToWebview()
22872292

2288-
// Send success message to webview
2293+
// Send success message to webview, include the imported slug so UI can switch
22892294
provider.postMessageToWebview({
22902295
type: "importModeResult",
22912296
success: true,
2297+
slug: result.slug,
22922298
})
22932299

22942300
// Show success message

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

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getCustomInstructions,
2121
getAllModes,
2222
findModeBySlug as findCustomModeBySlug,
23+
defaultModeSlug,
2324
} from "@roo/modes"
2425
import { TOOL_GROUPS } from "@roo/tools"
2526

@@ -55,6 +56,8 @@ const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group)
5556

5657
type ModeSource = "global" | "project"
5758

59+
type ImportModeResult = { type: "importModeResult"; success: boolean; slug?: string; error?: string }
60+
5861
type ModesViewProps = {
5962
onDone: () => void
6063
}
@@ -186,6 +189,29 @@ const ModesView = ({ onDone }: ModesViewProps) => {
186189
[visualMode, switchMode],
187190
)
188191

192+
// Refs to track latest state/functions for message handler (which has no dependencies)
193+
const handleModeSwitchRef = useRef(handleModeSwitch)
194+
const customModesRef = useRef(customModes)
195+
const switchModeRef = useRef(switchMode)
196+
197+
// Update refs when dependencies change
198+
useEffect(() => {
199+
handleModeSwitchRef.current = handleModeSwitch
200+
}, [handleModeSwitch])
201+
202+
useEffect(() => {
203+
customModesRef.current = customModes
204+
}, [customModes])
205+
206+
useEffect(() => {
207+
switchModeRef.current = switchMode
208+
}, [switchMode])
209+
210+
// Sync visualMode with backend mode changes to prevent desync
211+
useEffect(() => {
212+
setVisualMode(mode)
213+
}, [mode])
214+
189215
// Handler for popover open state change
190216
const onOpenChange = useCallback((open: boolean) => {
191217
setOpen(open)
@@ -460,7 +486,21 @@ const ModesView = ({ onDone }: ModesViewProps) => {
460486
setIsImporting(false)
461487
setShowImportDialog(false)
462488

463-
if (!message.success) {
489+
if (message.success) {
490+
const { slug } = message as ImportModeResult
491+
if (slug) {
492+
// Try switching using the freshest mode list available
493+
const all = getAllModes(customModesRef.current)
494+
const importedMode = all.find((m) => m.slug === slug)
495+
if (importedMode) {
496+
handleModeSwitchRef.current(importedMode)
497+
} else {
498+
// Fallback: slug not yet in state (race condition) - select default mode
499+
setVisualMode(defaultModeSlug)
500+
switchModeRef.current?.(defaultModeSlug)
501+
}
502+
}
503+
} else {
464504
// Only log error if it's not a cancellation
465505
if (message.error !== "cancelled") {
466506
console.error("Failed to import mode:", message.error)
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// npx vitest src/components/modes/__tests__/ModesView.import-switch.spec.tsx
2+
3+
import { render, waitFor } from "@/utils/test-utils"
4+
import ModesView from "../ModesView"
5+
import { ExtensionStateContext } from "@src/context/ExtensionStateContext"
6+
import { vscode } from "@src/utils/vscode"
7+
import { defaultModeSlug } from "@roo/modes"
8+
9+
// Mock vscode API
10+
vitest.mock("@src/utils/vscode", () => ({
11+
vscode: {
12+
postMessage: vitest.fn(),
13+
},
14+
}))
15+
16+
const mockExtensionState = {
17+
customModePrompts: {},
18+
listApiConfigMeta: [
19+
{ id: "config1", name: "Config 1" },
20+
{ id: "config2", name: "Config 2" },
21+
],
22+
enhancementApiConfigId: "",
23+
setEnhancementApiConfigId: vitest.fn(),
24+
mode: "code",
25+
customModes: [],
26+
customSupportPrompts: [],
27+
currentApiConfigName: "",
28+
customInstructions: "",
29+
setCustomInstructions: vitest.fn(),
30+
}
31+
32+
const renderModesView = (props = {}) => {
33+
const mockOnDone = vitest.fn()
34+
return render(
35+
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
36+
<ModesView onDone={mockOnDone} />
37+
</ExtensionStateContext.Provider>,
38+
)
39+
}
40+
41+
Element.prototype.scrollIntoView = vitest.fn()
42+
43+
describe("ModesView Import Auto-Switch", () => {
44+
beforeEach(() => {
45+
vitest.clearAllMocks()
46+
})
47+
48+
it("should auto-switch to imported mode when found in current state", async () => {
49+
const importedModeSlug = "custom-test-mode"
50+
const customModes = [
51+
{
52+
slug: importedModeSlug,
53+
name: "Custom Test Mode",
54+
roleDefinition: "Test role",
55+
groups: [],
56+
},
57+
]
58+
59+
renderModesView({ customModes })
60+
61+
// Simulate successful import message with the mode already in state
62+
const importMessage = {
63+
data: {
64+
type: "importModeResult",
65+
success: true,
66+
slug: importedModeSlug,
67+
},
68+
}
69+
70+
window.dispatchEvent(new MessageEvent("message", importMessage))
71+
72+
// Wait for the mode switch message to be sent
73+
await waitFor(() => {
74+
expect(vscode.postMessage).toHaveBeenCalledWith({
75+
type: "mode",
76+
text: importedModeSlug,
77+
})
78+
})
79+
})
80+
81+
it("should fallback to architect mode when imported slug not yet in state (race condition)", async () => {
82+
const importedModeSlug = "custom-new-mode"
83+
84+
// Render without the imported mode in customModes (simulating race condition)
85+
renderModesView({ customModes: [] })
86+
87+
// Simulate successful import message but mode not yet in state
88+
const importMessage = {
89+
data: {
90+
type: "importModeResult",
91+
success: true,
92+
slug: importedModeSlug,
93+
},
94+
}
95+
96+
window.dispatchEvent(new MessageEvent("message", importMessage))
97+
98+
// Wait for the fallback to default mode (architect)
99+
await waitFor(() => {
100+
expect(vscode.postMessage).toHaveBeenCalledWith({
101+
type: "mode",
102+
text: defaultModeSlug,
103+
})
104+
})
105+
})
106+
107+
it("should not switch modes on import failure", async () => {
108+
renderModesView()
109+
110+
// Simulate failed import message
111+
const importMessage = {
112+
data: {
113+
type: "importModeResult",
114+
success: false,
115+
error: "Import failed",
116+
},
117+
}
118+
119+
window.dispatchEvent(new MessageEvent("message", importMessage))
120+
121+
// Wait a bit to ensure no mode switch happens
122+
await new Promise((resolve) => setTimeout(resolve, 100))
123+
124+
// Verify no mode switch message was sent
125+
expect(vscode.postMessage).not.toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
type: "mode",
128+
}),
129+
)
130+
})
131+
132+
it("should not switch modes on cancelled import", async () => {
133+
renderModesView()
134+
135+
// Simulate cancelled import message
136+
const importMessage = {
137+
data: {
138+
type: "importModeResult",
139+
success: false,
140+
error: "cancelled",
141+
},
142+
}
143+
144+
window.dispatchEvent(new MessageEvent("message", importMessage))
145+
146+
// Wait a bit to ensure no mode switch happens
147+
await new Promise((resolve) => setTimeout(resolve, 100))
148+
149+
// Verify no mode switch message was sent
150+
expect(vscode.postMessage).not.toHaveBeenCalledWith(
151+
expect.objectContaining({
152+
type: "mode",
153+
}),
154+
)
155+
})
156+
})

0 commit comments

Comments
 (0)