Skip to content

Commit f07b1c0

Browse files
daniel-lxsheyseth
andcommitted
feat: auto-switch to imported mode with architect fallback
Based on PR #8521 by @heyseth (Seth Miller) with the following enhancements: Original work by Seth Miller: - Auto-switch to imported mode after successful import - Backend returns imported mode slug in importModeWithRules() - UI switches to imported mode when found in customModes list - Comprehensive test coverage for auto-switch functionality Additional improvements: - Use architect mode as fallback when imported slug not yet present - Updated test to expect architect mode in fallback scenario - Prevents UI desync during state refresh race conditions Co-authored-by: Seth Miller <[email protected]>
1 parent 5183aec commit f07b1c0

File tree

2 files changed

+118
-79
lines changed

2 files changed

+118
-79
lines changed

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

Lines changed: 10 additions & 8 deletions
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,7 +56,7 @@ const availableGroups = (Object.keys(TOOL_GROUPS) as ToolGroup[]).filter((group)
5556

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

58-
type ImportModeResult = { type: 'importModeResult'; success: boolean; slug?: string; error?: string }
59+
type ImportModeResult = { type: "importModeResult"; success: boolean; slug?: string; error?: string }
5960

6061
type ModesViewProps = {
6162
onDone: () => void
@@ -188,19 +189,20 @@ const ModesView = ({ onDone }: ModesViewProps) => {
188189
[visualMode, switchMode],
189190
)
190191

191-
// Keep latest handleModeSwitch and customModes available inside window message handler
192+
// Refs to track latest state/functions for message handler (which has no dependencies)
192193
const handleModeSwitchRef = useRef(handleModeSwitch)
194+
const customModesRef = useRef(customModes)
195+
const switchModeRef = useRef(switchMode)
196+
197+
// Update refs when dependencies change
193198
useEffect(() => {
194199
handleModeSwitchRef.current = handleModeSwitch
195200
}, [handleModeSwitch])
196201

197-
const customModesRef = useRef(customModes)
198202
useEffect(() => {
199203
customModesRef.current = customModes
200204
}, [customModes])
201205

202-
// Keep latest switchMode available inside window message handler
203-
const switchModeRef = useRef(switchMode)
204206
useEffect(() => {
205207
switchModeRef.current = switchMode
206208
}, [switchMode])
@@ -493,9 +495,9 @@ const ModesView = ({ onDone }: ModesViewProps) => {
493495
if (importedMode) {
494496
handleModeSwitchRef.current(importedMode)
495497
} else {
496-
// Fallback: switch by slug to keep backend in sync and update visual selection
497-
setVisualMode(slug)
498-
switchModeRef.current?.(slug)
498+
// Fallback: slug not yet in state (race condition) - select architect mode
499+
setVisualMode("architect")
500+
switchModeRef.current?.("architect")
499501
}
500502
}
501503
} else {
Lines changed: 108 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
import { render, screen, waitFor } from "@/utils/test-utils"
1+
// npx vitest src/components/modes/__tests__/ModesView.import-switch.spec.tsx
2+
3+
import { render, waitFor } from "@/utils/test-utils"
24
import ModesView from "../ModesView"
35
import { ExtensionStateContext } from "@src/context/ExtensionStateContext"
46
import { vscode } from "@src/utils/vscode"
57

8+
// Mock vscode API
69
vitest.mock("@src/utils/vscode", () => ({
710
vscode: {
811
postMessage: vitest.fn(),
912
},
1013
}))
1114

12-
const baseState = {
15+
const mockExtensionState = {
1316
customModePrompts: {},
14-
listApiConfigMeta: [],
17+
listApiConfigMeta: [
18+
{ id: "config1", name: "Config 1" },
19+
{ id: "config2", name: "Config 2" },
20+
],
1521
enhancementApiConfigId: "",
1622
setEnhancementApiConfigId: vitest.fn(),
1723
mode: "code",
@@ -22,97 +28,128 @@ const baseState = {
2228
setCustomInstructions: vitest.fn(),
2329
}
2430

25-
describe("ModesView - auto switch after import", () => {
31+
const renderModesView = (props = {}) => {
32+
const mockOnDone = vitest.fn()
33+
return render(
34+
<ExtensionStateContext.Provider value={{ ...mockExtensionState, ...props } as any}>
35+
<ModesView onDone={mockOnDone} />
36+
</ExtensionStateContext.Provider>,
37+
)
38+
}
39+
40+
Element.prototype.scrollIntoView = vitest.fn()
41+
42+
describe("ModesView Import Auto-Switch", () => {
2643
beforeEach(() => {
2744
vitest.clearAllMocks()
2845
})
2946

30-
it("switches to imported mode when import succeeds and slug is provided", async () => {
31-
const importedMode = {
32-
slug: "imported-mode",
33-
name: "Imported Mode",
34-
roleDefinition: "Role",
35-
groups: ["read"] as const,
36-
source: "global" as const,
47+
it("should auto-switch to imported mode when found in current state", async () => {
48+
const importedModeSlug = "custom-test-mode"
49+
const customModes = [
50+
{
51+
slug: importedModeSlug,
52+
name: "Custom Test Mode",
53+
roleDefinition: "Test role",
54+
groups: [],
55+
},
56+
]
57+
58+
renderModesView({ customModes })
59+
60+
// Simulate successful import message with the mode already in state
61+
const importMessage = {
62+
data: {
63+
type: "importModeResult",
64+
success: true,
65+
slug: importedModeSlug,
66+
},
3767
}
3868

39-
render(
40-
<ExtensionStateContext.Provider value={{ ...baseState, customModes: [importedMode] } as any}>
41-
<ModesView onDone={vitest.fn()} />
42-
</ExtensionStateContext.Provider>,
43-
)
44-
45-
const trigger = screen.getByTestId("mode-select-trigger")
46-
expect(trigger).toHaveTextContent("Code")
47-
48-
// Simulate extension sending successful import result with slug
49-
window.dispatchEvent(
50-
new MessageEvent("message", {
51-
data: { type: "importModeResult", success: true, slug: "imported-mode" },
52-
}),
53-
)
54-
55-
// Backend switch message sent
56-
await waitFor(() => {
57-
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "imported-mode" })
58-
})
69+
window.dispatchEvent(new MessageEvent("message", importMessage))
5970

60-
// UI reflects new mode selection
71+
// Wait for the mode switch message to be sent
6172
await waitFor(() => {
62-
expect(trigger).toHaveTextContent("Imported Mode")
73+
expect(vscode.postMessage).toHaveBeenCalledWith({
74+
type: "mode",
75+
text: importedModeSlug,
76+
})
6377
})
6478
})
6579

66-
it("does not switch when import fails or slug missing", async () => {
67-
render(
68-
<ExtensionStateContext.Provider value={{ ...baseState } as any}>
69-
<ModesView onDone={vitest.fn()} />
70-
</ExtensionStateContext.Provider>,
71-
)
72-
73-
const trigger = screen.getByTestId("mode-select-trigger")
74-
expect(trigger).toHaveTextContent("Code")
80+
it("should fallback to architect mode when imported slug not yet in state (race condition)", async () => {
81+
const importedModeSlug = "custom-new-mode"
7582

76-
// Import failure
77-
window.dispatchEvent(
78-
new MessageEvent("message", { data: { type: "importModeResult", success: false, error: "x" } }),
79-
)
83+
// Render without the imported mode in customModes (simulating race condition)
84+
renderModesView({ customModes: [] })
8085

81-
await waitFor(() => {
82-
expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "mode", text: expect.any(String) })
83-
})
84-
expect(trigger).toHaveTextContent("Code")
86+
// Simulate successful import message but mode not yet in state
87+
const importMessage = {
88+
data: {
89+
type: "importModeResult",
90+
success: true,
91+
slug: importedModeSlug,
92+
},
93+
}
8594

86-
// Success but no slug provided
87-
window.dispatchEvent(new MessageEvent("message", { data: { type: "importModeResult", success: true } }))
95+
window.dispatchEvent(new MessageEvent("message", importMessage))
8896

97+
// Wait for the fallback to architect mode
8998
await waitFor(() => {
90-
expect(vscode.postMessage).not.toHaveBeenCalledWith({ type: "mode", text: expect.any(String) })
99+
expect(vscode.postMessage).toHaveBeenCalledWith({
100+
type: "mode",
101+
text: "architect",
102+
})
91103
})
92-
expect(trigger).toHaveTextContent("Code")
93104
})
94105

95-
it("uses fallback branch when imported slug not yet present in customModes", async () => {
96-
// Render with empty customModes - imported mode hasn't been added to state yet
97-
render(
98-
<ExtensionStateContext.Provider value={{ ...baseState, customModes: [] } as any}>
99-
<ModesView onDone={vitest.fn()} />
100-
</ExtensionStateContext.Provider>,
101-
)
106+
it("should not switch modes on import failure", async () => {
107+
renderModesView()
108+
109+
// Simulate failed import message
110+
const importMessage = {
111+
data: {
112+
type: "importModeResult",
113+
success: false,
114+
error: "Import failed",
115+
},
116+
}
117+
118+
window.dispatchEvent(new MessageEvent("message", importMessage))
102119

103-
const trigger = screen.getByTestId("mode-select-trigger")
104-
expect(trigger).toHaveTextContent("Code")
120+
// Wait a bit to ensure no mode switch happens
121+
await new Promise((resolve) => setTimeout(resolve, 100))
105122

106-
// Simulate successful import for a slug not yet in customModes (timing race condition)
107-
window.dispatchEvent(
108-
new MessageEvent("message", {
109-
data: { type: "importModeResult", success: true, slug: "not-yet-loaded-mode" },
123+
// Verify no mode switch message was sent
124+
expect(vscode.postMessage).not.toHaveBeenCalledWith(
125+
expect.objectContaining({
126+
type: "mode",
110127
}),
111128
)
129+
})
112130

113-
// Fallback branch should send backend switch message
114-
await waitFor(() => {
115-
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "mode", text: "not-yet-loaded-mode" })
116-
})
131+
it("should not switch modes on cancelled import", async () => {
132+
renderModesView()
133+
134+
// Simulate cancelled import message
135+
const importMessage = {
136+
data: {
137+
type: "importModeResult",
138+
success: false,
139+
error: "cancelled",
140+
},
141+
}
142+
143+
window.dispatchEvent(new MessageEvent("message", importMessage))
144+
145+
// Wait a bit to ensure no mode switch happens
146+
await new Promise((resolve) => setTimeout(resolve, 100))
147+
148+
// Verify no mode switch message was sent
149+
expect(vscode.postMessage).not.toHaveBeenCalledWith(
150+
expect.objectContaining({
151+
type: "mode",
152+
}),
153+
)
117154
})
118155
})

0 commit comments

Comments
 (0)