feat: allow users to change mode when approving orchestrator subtasks

- Added ModeSelector component to newTask tool approval UI in ChatRow
- Users can now click on the mode to select a different one before approving
- Selected mode is passed through the askResponse flow to the backend
- Modified Task.ts to store and provide access to askResponseValues
- Updated newTaskTool.ts to use the user-selected mode if provided
- Added comprehensive tests for the new functionality
- Added translation key for mode selector tooltip

Fixes #6706
This commit is contained in:
Roo Code 2025-08-05 09:34:49 +00:00
parent d90bab71ff
commit 74f9150400
6 changed files with 239 additions and 15 deletions

View file

@ -225,8 +225,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private askResponse?: ClineAskResponse
private askResponseText?: string
private askResponseImages?: string[]
private askResponseValues?: Record<string, any>
public lastMessageTs?: number
// Getter for askResponseValues to allow tools to access it
get getAskResponseValues(): Record<string, any> | undefined {
return this.askResponseValues
}
// Tool Use
consecutiveMistakeCount: number = 0
consecutiveMistakeLimit: number
@ -742,10 +748,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.handleWebviewAskResponse("messageResponse", text, images)
}
handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) {
handleWebviewAskResponse(
askResponse: ClineAskResponse,
text?: string,
images?: string[],
values?: Record<string, any>,
) {
this.askResponse = askResponse
this.askResponseText = text
this.askResponseImages = images
// Store values for later use if needed
if (values) {
this.askResponseValues = values
}
}
async handleTerminalOperation(terminalOperation: "continue" | "abort") {

View file

@ -36,6 +36,7 @@ const mockCline = {
consecutiveMistakeCount: 0,
isPaused: false,
pausedModeSlug: "ask",
getAskResponseValues: undefined as Record<string, any> | undefined,
providerRef: {
deref: vi.fn(() => ({
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
@ -184,4 +185,143 @@ describe("newTaskTool", () => {
})
// Add more tests for error handling (missing params, invalid mode, approval denied) if needed
it("should use user-selected mode when provided in askResponseValues", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Create a new feature",
},
partial: false,
}
// Mock user selecting a different mode
mockCline.getAskResponseValues = { selectedMode: "architect" }
// Mock the architect mode
vi.mocked(getModeBySlug).mockImplementation((slug) => {
if (slug === "architect") {
return {
slug: "architect",
name: "Architect Mode",
roleDefinition: "Architecture role definition",
groups: ["command", "read"],
}
}
return {
slug: "code",
name: "Code Mode",
roleDefinition: "Test role definition",
groups: ["command", "read", "edit"],
}
})
const mockHandleModeSwitch = vi.fn()
mockCline.providerRef.deref = vi.fn(() => ({
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
handleModeSwitch: mockHandleModeSwitch,
initClineWithTask: mockInitClineWithTask,
}))
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify the mode switch was called with the user-selected mode
expect(mockHandleModeSwitch).toHaveBeenCalledWith("architect")
// Verify the success message includes the correct mode name
expect(mockPushToolResult).toHaveBeenCalledWith(
expect.stringContaining("Successfully created new task in Architect Mode"),
)
})
it("should use original mode when no user selection is provided", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Create a new feature",
},
partial: false,
}
// No user selection
mockCline.getAskResponseValues = undefined
const mockHandleModeSwitch = vi.fn()
mockCline.providerRef.deref = vi.fn(() => ({
getState: vi.fn(() => ({ customModes: [], mode: "ask" })),
handleModeSwitch: mockHandleModeSwitch,
initClineWithTask: mockInitClineWithTask,
}))
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify the mode switch was called with the original mode
expect(mockHandleModeSwitch).toHaveBeenCalledWith("code")
// Verify the success message includes the correct mode name
expect(mockPushToolResult).toHaveBeenCalledWith(
expect.stringContaining("Successfully created new task in Code Mode"),
)
})
it("should handle invalid user-selected mode gracefully", async () => {
const block: ToolUse = {
type: "tool_use",
name: "new_task",
params: {
mode: "code",
message: "Create a new feature",
},
partial: false,
}
// Mock user selecting an invalid mode
mockCline.getAskResponseValues = { selectedMode: "invalid-mode" }
// Mock getModeBySlug to return undefined for invalid mode
vi.mocked(getModeBySlug).mockImplementation((slug) => {
if (slug === "invalid-mode") {
return undefined
}
return {
slug: "code",
name: "Code Mode",
roleDefinition: "Test role definition",
groups: ["command", "read", "edit"],
}
})
await newTaskTool(
mockCline as any,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Verify error was pushed
expect(mockPushToolResult).toHaveBeenCalledWith("Tool Error: Invalid mode: invalid-mode")
// Verify no task was created
expect(mockInitClineWithTask).not.toHaveBeenCalled()
})
})

View file

@ -75,6 +75,17 @@ export async function newTaskTool(
return
}
// Check if user selected a different mode during approval
const selectedMode = cline.getAskResponseValues?.selectedMode as string | undefined
const finalMode = selectedMode || mode
// Verify the final mode exists
const finalTargetMode = getModeBySlug(finalMode, (await provider.getState())?.customModes)
if (!finalTargetMode) {
pushToolResult(formatResponse.toolError(`Invalid mode: ${finalMode}`))
return
}
if (cline.enableCheckpoints) {
cline.checkpointSave(true)
}
@ -89,15 +100,17 @@ export async function newTaskTool(
return
}
// Now switch the newly created task to the desired mode
await provider.handleModeSwitch(mode)
// Now switch the newly created task to the desired mode (using the final mode)
await provider.handleModeSwitch(finalMode)
// Delay to allow mode change to take effect
await delay(500)
cline.emit(RooCodeEventName.TaskSpawned, newCline.taskId)
pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${unescapedMessage}`)
pushToolResult(
`Successfully created new task in ${finalTargetMode.name} mode with message: ${unescapedMessage}`,
)
// Set the isPaused flag to true so the parent
// task can wait for the sub-task to finish.

View file

@ -33,6 +33,7 @@ import MarkdownBlock from "../common/MarkdownBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import ModeSelector from "./ModeSelector"
import { Mention } from "./Mention"
import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
@ -60,6 +61,7 @@ interface ChatRowProps {
onFollowUpUnmount?: () => void
isFollowUpAnswered?: boolean
editable?: boolean
onNewTaskModeChange?: (mode: string) => void
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
@ -112,9 +114,10 @@ export const ChatRowContent = ({
onBatchFileResponse,
isFollowUpAnswered,
editable,
onNewTaskModeChange,
}: ChatRowContentProps) => {
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, customModes, customModePrompts } = useExtensionState()
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
@ -122,6 +125,7 @@ export const ChatRowContent = ({
const [editedContent, setEditedContent] = useState("")
const [editMode, setEditMode] = useState<Mode>(mode || "code")
const [editImages, setEditImages] = useState<string[]>([])
const [selectedNewTaskMode, setSelectedNewTaskMode] = useState<Mode | null>(null)
const { copyWithFeedback } = useCopyToClipboard()
// Handle message events for image selection during edit mode
@ -765,16 +769,39 @@ export const ChatRowContent = ({
</>
)
case "newTask":
// Use the selected mode if available, otherwise use the tool's mode
const effectiveMode = selectedNewTaskMode || tool.mode
return (
<>
<div style={headerStyle}>
{toolIcon("tasklist")}
<span style={{ fontWeight: "bold" }}>
<Trans
i18nKey="chat:subtasks.wantsToCreate"
components={{ code: <code>{tool.mode}</code> }}
values={{ mode: tool.mode }}
/>
<span style={{ fontWeight: "bold", display: "flex", alignItems: "center", gap: "4px" }}>
{t("chat:subtasks.wantsToCreate").split("{mode}")[0]}
{message.type === "ask" ? (
<ModeSelector
value={effectiveMode as Mode}
onChange={(newMode) => {
setSelectedNewTaskMode(newMode)
// Update the tool data with the new mode
if (tool) {
tool.mode = newMode
}
// Notify parent component
onNewTaskModeChange?.(newMode)
}}
disabled={false}
title={t("chat:subtasks.selectMode")}
triggerClassName="inline-flex"
modeShortcutText=""
customModes={customModes}
customModePrompts={customModePrompts}
disableSearch={true}
/>
) : (
<code>{effectiveMode}</code>
)}
{t("chat:subtasks.wantsToCreate").split("{mode}")[1]}
</span>
</div>
<div

View file

@ -188,6 +188,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const userRespondedRef = useRef<boolean>(false)
const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
const [selectedNewTaskModes, setSelectedNewTaskModes] = useState<Record<number, string>>({})
const clineAskRef = useRef(clineAsk)
useEffect(() => {
@ -709,7 +710,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// after which buttons are shown and we then send an askResponse to the
// extension.
const handlePrimaryButtonClick = useCallback(
(text?: string, images?: string[]) => {
(text?: string, images?: string[], additionalData?: any) => {
// Mark that user has responded
userRespondedRef.current = true
@ -730,12 +731,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
askResponse: "yesButtonClicked",
text: trimmedInput,
images: images,
values: additionalData,
})
// Clear input state after sending
setInputValue("")
setSelectedImages([])
} else {
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
vscode.postMessage({
type: "askResponse",
askResponse: "yesButtonClicked",
values: additionalData,
})
}
break
case "completion_result":
@ -1506,6 +1512,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onBatchFileResponse={handleBatchFileResponse}
onFollowUpUnmount={handleFollowUpUnmount}
isFollowUpAnswered={messageOrGroup.ts === currentFollowUpTs}
onNewTaskModeChange={(mode: string) => {
setSelectedNewTaskModes((prev) => ({ ...prev, [messageOrGroup.ts]: mode }))
}}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
@ -1912,7 +1921,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
appearance="primary"
disabled={!enableButtons}
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
onClick={() => {
// Check if this is a newTask tool and we have a selected mode
let additionalData = undefined
if (lastMessage?.ask === "tool") {
try {
const tool = JSON.parse(lastMessage.text || "{}")
if (
tool.tool === "newTask" &&
selectedNewTaskModes[lastMessage.ts]
) {
additionalData = {
selectedMode: selectedNewTaskModes[lastMessage.ts],
}
}
} catch (_e) {
// Ignore parse errors
}
}
handlePrimaryButtonClick(inputValue, selectedImages, additionalData)
}}>
{primaryButtonText}
</VSCodeButton>
</StandardTooltip>

View file

@ -247,7 +247,8 @@
"completionContent": "Subtask Completed",
"resultContent": "Subtask Results",
"defaultResult": "Please continue to the next task.",
"completionInstructions": "Subtask completed! You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task."
"completionInstructions": "Subtask completed! You can review the results and suggest any corrections or next steps. If everything looks good, confirm to return the result to the parent task.",
"selectMode": "Click to select a different mode for this subtask"
},
"questions": {
"hasQuestion": "Roo has a question:"