Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition of the getter! Consider adding a JSDoc comment to document what this getter returns and when it's used. This would help future maintainers understand the askResponseValues flow better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition of the getter! Consider adding a JSDoc comment to document what this getter returns and when it's used. This would help future maintainers understand the askResponseValues flow better.

return this.askResponseValues
}

// Tool Use
consecutiveMistakeCount: number = 0
consecutiveMistakeLimit: number
Expand Down Expand Up @@ -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") {
Expand Down
140 changes: 140 additions & 0 deletions src/core/tools/__tests__/newTaskTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })),
Expand Down Expand Up @@ -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 () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great test coverage! The edge cases are well handled. Consider adding an integration test that verifies the full flow from UI interaction to task creation with the selected mode - this would give us confidence that all the pieces work together correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great test coverage! The edge cases are well handled. Consider adding an integration test that verifies the full flow from UI interaction to task creation with the selected mode - this would give us confidence that all the pieces work together correctly.

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()
})
})
19 changes: 16 additions & 3 deletions src/core/tools/newTaskTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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.
Expand Down
41 changes: 34 additions & 7 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -112,16 +114,18 @@ 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)
const [isEditing, setIsEditing] = useState(false)
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
Expand Down Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode selector integration looks clean! A small UI enhancement to consider: when in "ask" state, the current mode could be shown more prominently to help users understand what they're changing from.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode selector integration looks clean! A small UI enhancement to consider: when in "ask" state, the current mode could be shown more prominently to help users understand what they're changing from.

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
Expand Down
34 changes: 31 additions & 3 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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

Expand All @@ -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":
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -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") {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the best way to handle the type checking? The try-catch for JSON parsing works, but we could extract this logic into a helper function for better reusability and type safety.

Also, I notice the state grows unbounded as messages accumulate. Should we consider cleaning up old entries when tasks complete to prevent potential memory issues in long-running sessions?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the best way to handle the type checking? The try-catch for JSON parsing works, but we could extract this logic into a helper function for better reusability and type safety.

Also, I notice the selectedNewTaskModes state grows unbounded as messages accumulate. Should we consider cleaning up old entries when tasks complete to prevent potential memory issues in long-running sessions?

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>
Expand Down
3 changes: 2 additions & 1 deletion webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading