Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
* - `condense_context_error`: Error occurred during context condensation
* - `codebase_search_result`: Results from searching the codebase
* - `too_many_tools_warning`: Warning that too many MCP tools are enabled, which may confuse the LLM
* - `inline_subtask_started`: A subtask was auto-flattened and is now executing inline in this conversation
* - `inline_subtask_rejected`: A nested new_task call was rejected (an inline phase is already active)
*/
export const clineSays = [
"error",
Expand All @@ -161,6 +163,8 @@ export const clineSays = [
"mcp_server_request_started",
"mcp_server_response",
"subtask_result",
"inline_subtask_started",
"inline_subtask_rejected",
"checkpoint_saved",
"rooignore_error",
"diff_error",
Expand Down
13 changes: 13 additions & 0 deletions src/core/tools/NewTaskTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,31 @@ export class NewTaskTool extends BaseTool<"new_task"> {
})

if (decision.action === "reject-nested") {
// Surface the rejection in the UI as well — tool results are not rendered.
// Structured payload so the webview can localize the detail text.
await task.say("inline_subtask_rejected", JSON.stringify({ reason: "nested" }))
pushToolResult(formatResponse.toolError(decision.message))
return
}

if (decision.action === "flatten") {
// Set the phase marker and let the tool_result double as the inline prompt.
task.inlineSubtask = { message: unescapedMessage, todos: todoItems }
// Surface the auto-flatten in the UI — the model sees it via the tool result,
// but without this the user has no indication that a subtask now runs inline.
// Structured payload so the webview can localize the detail text.
await task.say("inline_subtask_started", JSON.stringify({ maxDepth: maxNestingDepth }))
pushToolResult(decision.directive)
return
}

if (decision.action === "reject-limit") {
// Surface the rejection in the UI as well — tool results are not rendered.
// Structured payload so the webview can localize the detail text.
await task.say(
"inline_subtask_rejected",
JSON.stringify({ reason: "limit", maxDepth: maxNestingDepth }),
)
pushToolResult(formatResponse.toolError(decision.message))
return
}
Expand Down
10 changes: 10 additions & 0 deletions src/core/tools/__tests__/newTaskInlineFlatten.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function makeTask(opts: { depth?: number; inlineSubtask?: InlineSubtask; provide
didToolFailInCurrentTurn: false,
recordToolError: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("missing param"),
say: vi.fn().mockResolvedValue(undefined),
providerRef: { deref: () => opts.provider },
}
return task as unknown as Task
Expand Down Expand Up @@ -91,6 +92,9 @@ describe("NewTaskTool auto-flatten inline", () => {
const pushed = pushToolResult.mock.calls[0][0] as string
expect(pushed).toContain("auto-flattened")
expect(pushed).toContain("do X")
// The auto-flatten is surfaced to the UI via a structured say payload (tool results are not rendered).
const say = (task as unknown as { say: ReturnType<typeof vi.fn> }).say
expect(say).toHaveBeenCalledWith("inline_subtask_started", JSON.stringify({ maxDepth: 2 }))
})

it("rejects when over the limit and autoFlattenOnLimit is false (error result, no marker)", async () => {
Expand All @@ -109,6 +113,9 @@ describe("NewTaskTool auto-flatten inline", () => {
expect(task.inlineSubtask).toBeUndefined()
const pushed = pushToolResult.mock.calls[0][0] as string
expect(pushed.toLowerCase()).toContain("error")
// The rejection is surfaced to the UI via a structured say payload.
const say = (task as unknown as { say: ReturnType<typeof vi.fn> }).say
expect(say).toHaveBeenCalledWith("inline_subtask_rejected", JSON.stringify({ reason: "limit", maxDepth: 2 }))
})

it("rejects a nested new_task while an inline phase is already active", async () => {
Expand All @@ -128,6 +135,9 @@ describe("NewTaskTool auto-flatten inline", () => {
expect(task.inlineSubtask?.message).toBe("outer")
const pushed = pushToolResult.mock.calls[0][0] as string
expect(pushed.toLowerCase()).toContain("error")
// The rejection is surfaced to the UI via a structured say payload.
const say = (task as unknown as { say: ReturnType<typeof vi.fn> }).say
expect(say).toHaveBeenCalledWith("inline_subtask_rejected", JSON.stringify({ reason: "nested" }))
})
})

Expand Down
74 changes: 74 additions & 0 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
ArrowRight,
Check,
OctagonX,
Settings,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { PathTooltip } from "../ui/PathTooltip"
Expand Down Expand Up @@ -1067,6 +1068,79 @@ export const ChatRowContent = ({
)}
</div>
)
case "inline_subtask_started": {
// A subtask was auto-flattened and now runs inline in this conversation.
const started = safeJsonParse<{ maxDepth?: number }>(message.text)
return (
<div className="group pr-2 py-1">
<div className="flex items-center gap-2 break-words">
<Split className="w-4 text-vscode-editorWarning-foreground shrink-0" />
<span className="font-bold text-vscode-editorWarning-foreground grow cursor-default">
{t("chat:subtasks.inlineStarted")}
</span>
</div>
<div className="cursor-default ml-2 pl-4 mt-1 pt-0.5 border-l border-vscode-editorWarning-foreground/50">
<p className="my-0 font-light whitespace-pre-wrap break-words text-vscode-descriptionForeground">
{started?.maxDepth != null
? t("chat:subtasks.inlineStartedDetail", { maxDepth: started.maxDepth })
: message.text}
</p>
<a
href="#"
className="mt-1 inline-flex items-center gap-1 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground cursor-pointer"
onClick={(e) => {
e.preventDefault()
vscode.postMessage({
type: "switchTab",
tab: "settings",
values: { section: "contextManagement" },
})
}}>
<Settings className="size-3.5" />
{t("chat:subtasks.inlineConfigure")}
</a>
</div>
</div>
)
}
case "inline_subtask_rejected": {
// A nested new_task was rejected (an inline phase is already active, or the
// nesting limit was hit with auto-flatten disabled).
const rejected = safeJsonParse<{ reason?: string; maxDepth?: number }>(message.text)
return (
<div className="group pr-2 py-1">
<div className="flex items-center gap-2 break-words">
<OctagonX className="w-4 text-vscode-errorForeground shrink-0" />
<span className="font-bold text-vscode-errorForeground grow cursor-default">
{t("chat:subtasks.inlineRejected")}
</span>
</div>
<div className="cursor-default ml-2 pl-4 mt-1 pt-0.5 border-l border-vscode-errorForeground/50">
<p className="my-0 font-light whitespace-pre-wrap break-words text-vscode-descriptionForeground">
{rejected?.reason === "limit"
? t("chat:subtasks.inlineRejectedLimitDetail", { maxDepth: rejected.maxDepth })
: rejected?.reason === "nested"
? t("chat:subtasks.inlineRejectedNestedDetail")
: message.text}
</p>
<a
href="#"
className="mt-1 inline-flex items-center gap-1 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground cursor-pointer"
onClick={(e) => {
e.preventDefault()
vscode.postMessage({
type: "switchTab",
tab: "settings",
values: { section: "contextManagement" },
})
}}>
<Settings className="size-3.5" />
{t("chat:subtasks.inlineConfigure")}
</a>
</div>
</div>
)
}
case "reasoning":
return (
<ReasoningBlock
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React from "react"
import { render, screen, fireEvent } from "@/utils/test-utils"
import { ChatRowContent } from "../ChatRow"
import type { ClineMessage } from "@roo-code/types"

// Mock vscode API
const mockPostMessage = vi.fn()
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: (msg: unknown) => mockPostMessage(msg),
},
}))

// Mock i18n — the two inline-subtask banner titles plus a fallback to the key itself.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
const map: Record<string, string> = {
"chat:subtasks.inlineStarted": "Subtask flattened to inline",
"chat:subtasks.inlineRejected": "Nested subtask rejected",
"chat:subtasks.inlineConfigure": "Adjust task tree settings",
"chat:subtasks.inlineStartedDetail":
"Nesting limit {{maxDepth}} reached — subtask flattened and executing inline in this conversation.",
"chat:subtasks.inlineRejectedLimitDetail":
"Nesting limit {{maxDepth}} reached and auto-flatten is disabled. Continue working directly in the current conversation instead of delegating.",
"chat:subtasks.inlineRejectedNestedDetail":
"Cannot start a nested subtask while an inline subtask is already in progress. Complete the current inline subtask with attempt_completion first.",
}
const raw = map[key] ?? key
if (!options) return raw
// Substitute {{var}} placeholders from the options object.
return raw.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options[name] ?? `{{${name}}}`))
},
i18n: { exists: () => true },
}),
Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
initReactI18next: { type: "3rdParty", init: () => {} },
}))

// Mock extension state context
vi.mock("@src/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
mcpServers: [],
alwaysAllowMcp: false,
currentCheckpoint: null,
mode: "code",
apiConfiguration: {},
clineMessages: [] as ClineMessage[],
currentTaskItem: undefined,
}),
}))

// Mock useSelectedModel hook
vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({
useSelectedModel: () => ({ info: { supportsImages: true } }),
}))

function renderChatRow(message: ClineMessage) {
return render(
<ChatRowContent
message={message}
isExpanded={false}
isLast={false}
isStreaming={false}
onToggleExpand={() => {}}
onSuggestionClick={() => {}}
onBatchFileResponse={() => {}}
onFollowUpUnmount={() => {}}
isFollowUpAnswered={false}
/>,
)
}

describe("ChatRow - inline subtask banners", () => {
it("renders a distinct banner when a subtask is auto-flattened to inline", () => {
const message: ClineMessage = {
ts: Date.now(),
type: "say" as const,
say: "inline_subtask_started" as const,
text: JSON.stringify({ maxDepth: 2 }),
}

renderChatRow(message)

// Banner title (i18n) is present…
expect(screen.getByText("Subtask flattened to inline")).toBeInTheDocument()
// …and the localized detail text renders below it.
expect(
screen.getByText("Nesting limit 2 reached — subtask flattened and executing inline in this conversation."),
).toBeInTheDocument()
})

it.each([
[
"nested",
JSON.stringify({ reason: "nested" }),
"Cannot start a nested subtask while an inline subtask is already in progress. Complete the current inline subtask with attempt_completion first.",
],
[
"limit",
JSON.stringify({ reason: "limit", maxDepth: 2 }),
"Nesting limit 2 reached and auto-flatten is disabled. Continue working directly in the current conversation instead of delegating.",
],
] as const)("renders a distinct banner when a new_task is rejected (%s)", (_reason, text, detail) => {
const message: ClineMessage = {
ts: Date.now(),
type: "say" as const,
say: "inline_subtask_rejected" as const,
text,
}

renderChatRow(message)

expect(screen.getByText("Nested subtask rejected")).toBeInTheDocument()
expect(screen.getByText(detail)).toBeInTheDocument()
})

describe("settings hint link", () => {
beforeEach(() => {
mockPostMessage.mockClear()
})

it.each(["inline_subtask_started", "inline_subtask_rejected"] as const)(
"deep-links the %s banner into the task-tree settings section",
(say) => {
const message: ClineMessage = {
ts: Date.now(),
type: "say" as const,
say,
text: "detail",
}

renderChatRow(message)

// The banner renders its settings-hint link…
const link = screen.getByText("Adjust task tree settings")
expect(link).toBeInTheDocument()
// …and clicking it switches to the settings tab, deep-linked to contextManagement.
fireEvent.click(link)
expect(mockPostMessage).toHaveBeenCalledWith({
type: "switchTab",
tab: "settings",
values: { section: "contextManagement" },
})
},
)
})

it("does not render the banners for unrelated say types", () => {
const message: ClineMessage = {
ts: Date.now(),
type: "say" as const,
say: "text" as const,
text: "ordinary model text",
}

renderChatRow(message)

expect(screen.queryByText("Subtask flattened to inline")).not.toBeInTheDocument()
expect(screen.queryByText("Nested subtask rejected")).not.toBeInTheDocument()
})
})
14 changes: 8 additions & 6 deletions webview-ui/src/components/settings/ContextManagementSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,8 +442,10 @@ export const ContextManagementSettings = ({
<SearchableSetting
settingId="context-max-nesting-depth"
section="contextManagement"
label={t("settings:taskTree.maxNestingDepth.label")}>
<span className="block font-medium mb-1">{t("settings:taskTree.maxNestingDepth.label")}</span>
label={t("settings:contextManagement.taskTree.maxNestingDepth.label")}>
<span className="block font-medium mb-1">
{t("settings:contextManagement.taskTree.maxNestingDepth.label")}
</span>
<div className="flex items-center gap-2">
<Slider
min={0}
Expand All @@ -456,24 +458,24 @@ export const ContextManagementSettings = ({
<span className="w-10">{maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH}</span>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:taskTree.maxNestingDepth.description")}
{t("settings:contextManagement.taskTree.maxNestingDepth.description")}
</div>
</SearchableSetting>

<SearchableSetting
settingId="context-auto-flatten-on-limit"
section="contextManagement"
label={t("settings:taskTree.autoFlattenOnLimit.label")}>
label={t("settings:contextManagement.taskTree.autoFlattenOnLimit.label")}>
<VSCodeCheckbox
checked={autoFlattenOnLimit ?? DEFAULT_AUTO_FLATTEN_ON_LIMIT}
onChange={(e: any) => setCachedStateField("autoFlattenOnLimit", e.target.checked)}
data-testid="auto-flatten-on-limit-checkbox">
<label className="block font-medium mb-1">
{t("settings:taskTree.autoFlattenOnLimit.label")}
{t("settings:contextManagement.taskTree.autoFlattenOnLimit.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:taskTree.autoFlattenOnLimit.description")}
{t("settings:contextManagement.taskTree.autoFlattenOnLimit.description")}
</div>
</SearchableSetting>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,16 @@ describe("ContextManagementSettings", () => {
})

describe("taskTree settings", () => {
// Regression: the task-tree controls must resolve their i18n keys under
// contextManagement.taskTree.* (where the translations live), not a top-level
// taskTree.* — otherwise the raw key is rendered in the UI.
it("resolves task-tree labels via the nested contextManagement.taskTree path", () => {
render(<ContextManagementSettings {...defaultProps} />)

expect(screen.getByText("settings:contextManagement.taskTree.maxNestingDepth.label")).toBeInTheDocument()
expect(screen.queryByText("settings:taskTree.maxNestingDepth.label")).not.toBeInTheDocument()
})

it("renders max nesting depth slider with default value when unset", () => {
render(<ContextManagementSettings {...defaultProps} />)

Expand Down
Loading
Loading