mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
d90bab71ff
commit
74f9150400
6 changed files with 239 additions and 15 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue