diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index bd440512ce..7fefe5fde9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -48,6 +48,25 @@ export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0 export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false +/** + * Default maximum task nesting depth (root = 0). + * + * A value of `0` disables delegation entirely — every `new_task` call is executed + * inline in the current conversation instead of opening a child tab. The range is + * clamped to 0–5 by the settings UI and validated here. + */ +export const DEFAULT_MAX_NESTING_DEPTH = 2 + +/** + * Default for auto-flattening when the nesting limit is reached. + * + * When `true`, a `new_task` call that would exceed `maxNestingDepth` is executed inline + * in the current conversation (same Task instance, phase marker) rather than opening a + * child tab. When `false`, such a call is rejected with an error result so the model + * continues working directly. + */ +export const DEFAULT_AUTO_FLATTEN_ON_LIMIT = true + /** * Terminal output preview size options for persisted command output. * @@ -151,6 +170,18 @@ export const globalSettingsSchema = z.object({ autoCondenseContext: z.boolean().optional(), autoCondenseContextPercent: z.number().optional(), + /** + * Maximum task nesting depth (root = 0). Range 0–5; `0` disables delegation entirely. + * @default 2 + */ + maxNestingDepth: z.number().int().min(0).max(5).optional(), + /** + * When the nesting limit is reached, execute the subtask inline in the current + * conversation instead of opening a child tab. When `false`, such calls are rejected. + * @default true + */ + autoFlattenOnLimit: z.boolean().optional(), + /** * Whether to include current time in the environment details * @default true diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..663cf0348b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -337,6 +337,8 @@ export type ExtensionState = Pick< writeDelayMs: number diffFuzzyThreshold: number + maxNestingDepth?: number // Maximum task nesting depth (root = 0); default 2, range 0–5 + autoFlattenOnLimit?: boolean // Execute subtasks inline when the nesting limit is reached; default true enableCheckpoints: boolean checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9dd872139c..038cd57710 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -43,6 +43,8 @@ import { DEFAULT_WRITE_DELAY_MS, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, + DEFAULT_MAX_NESTING_DEPTH, + DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, @@ -2486,6 +2488,8 @@ export class ClineProvider soundVolume, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -2644,6 +2648,8 @@ export class ClineProvider soundVolume: soundVolume ?? 0.5, writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true, terminalCommandDelay: terminalCommandDelay ?? 0, @@ -2873,6 +2879,8 @@ export class ClineProvider soundVolume: stateValues.soundVolume, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + maxNestingDepth: stateValues.maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH, + autoFlattenOnLimit: stateValues.autoFlattenOnLimit ?? DEFAULT_AUTO_FLATTEN_ON_LIMIT, terminalShellIntegrationTimeout: stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6167158ccc..dbe1ec9aaf 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1257,6 +1257,88 @@ describe("ClineProvider", () => { expect(state.diffFuzzyThreshold).toBe(0.5) }) + describe("taskTree settings round-trip", () => { + test("getState defaults maxNestingDepth to 2 and autoFlattenOnLimit to true when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Ensure the settings are not set so their documented defaults apply. + await provider.contextProxy.setValue("maxNestingDepth", undefined) + await provider.contextProxy.setValue("autoFlattenOnLimit", undefined) + + const state = await provider.getState() + expect(state.maxNestingDepth).toBe(2) + expect(state.autoFlattenOnLimit).toBe(true) + }) + + test("handles maxNestingDepth message and clamps out-of-range values", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock + .calls[0][0] + + // In-range value persists unchanged. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: 3 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 3) + + // Above the range clamps to 5. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: 9 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 5) + + // Below the range clamps to 0 (delegation disabled). + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: -1 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 0) + + // Non-numeric falls back to the default. + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: "abc" } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("maxNestingDepth", 2) + + // Unset values are not persisted (skipped). + const before = updateGlobalStateSpy.mock.calls.length + await messageHandler({ type: "updateSettings", updatedSettings: { maxNestingDepth: undefined } }) + expect(updateGlobalStateSpy).toHaveBeenCalledTimes(before) + }) + + test("handles autoFlattenOnLimit message as a boolean", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock + .calls[0][0] + + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: false } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoFlattenOnLimit", false) + + // A truthy non-boolean is normalized to true. + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: 1 } }) + expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoFlattenOnLimit", true) + + // Unset values are not persisted (skipped). + const before = updateGlobalStateSpy.mock.calls.length + await messageHandler({ type: "updateSettings", updatedSettings: { autoFlattenOnLimit: undefined } }) + expect(updateGlobalStateSpy).toHaveBeenCalledTimes(before) + }) + + test("getStateToPostToWebview returns saved taskTree values", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.contextProxy.setValue("maxNestingDepth", 4) + await provider.contextProxy.setValue("autoFlattenOnLimit", false) + + const state = await provider.getStateToPostToWebview() + expect(state.maxNestingDepth).toBe(4) + expect(state.autoFlattenOnLimit).toBe(false) + }) + + test("getStateToPostToWebview defaults taskTree values when unset", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Ensure the settings are not set so their documented defaults apply. + await provider.contextProxy.setValue("maxNestingDepth", undefined) + await provider.contextProxy.setValue("autoFlattenOnLimit", undefined) + + const state = await provider.getStateToPostToWebview() + expect(state.maxNestingDepth).toBe(2) + expect(state.autoFlattenOnLimit).toBe(true) + }) + }) + it("loads saved API config when switching modes", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f0fc33501f..a0123b1ba3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,8 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + DEFAULT_MAX_NESTING_DEPTH, + DEFAULT_AUTO_FLATTEN_ON_LIMIT, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -796,6 +798,21 @@ export const webviewMessageHandler = async ( if (!value) { continue } + } else if (key === "maxNestingDepth") { + // Normalize to an integer clamped to 0–5; skip persistence when unset. + if (value === undefined || value === null) { + continue + } + const parsed = Math.round(Number(value)) + newValue = Number.isFinite(parsed) + ? Math.min(5, Math.max(0, parsed)) + : DEFAULT_MAX_NESTING_DEPTH + } else if (key === "autoFlattenOnLimit") { + // Persist as a boolean; skip persistence when unset. + if (value === undefined || value === null) { + continue + } + newValue = Boolean(value) } await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index c3af315d99..cf0f09e313 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -3,7 +3,7 @@ import React from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react" import { ListChevronsDownUp } from "lucide-react" -import { DEFAULT_DIFF_FUZZY_THRESHOLD } from "@roo-code/types" +import { DEFAULT_AUTO_FLATTEN_ON_LIMIT, DEFAULT_DIFF_FUZZY_THRESHOLD, DEFAULT_MAX_NESTING_DEPTH } from "@roo-code/types" import { supportPrompt } from "@roo/support-prompt" @@ -40,6 +40,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxDiagnosticMessages?: number writeDelayMs: number diffFuzzyThreshold?: number + maxNestingDepth?: number + autoFlattenOnLimit?: boolean includeCurrentTime?: boolean includeCurrentCost?: boolean maxGitStatusFiles?: number @@ -59,6 +61,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxDiagnosticMessages" | "writeDelayMs" | "diffFuzzyThreshold" + | "maxNestingDepth" + | "autoFlattenOnLimit" | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" @@ -81,6 +85,8 @@ export const ContextManagementSettings = ({ maxDiagnosticMessages, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -433,6 +439,44 @@ export const ContextManagementSettings = ({ + + {t("settings:taskTree.maxNestingDepth.label")} +
+ setCachedStateField("maxNestingDepth", value)} + data-testid="max-nesting-depth-slider" + /> + {maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH} +
+
+ {t("settings:taskTree.maxNestingDepth.description")} +
+
+ + + setCachedStateField("autoFlattenOnLimit", e.target.checked)} + data-testid="auto-flatten-on-limit-checkbox"> + + +
+ {t("settings:taskTree.autoFlattenOnLimit.description")} +
+
+ (({ onDone, t terminalProfile, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, showRooIgnoredFiles, enableSubfolderRules, maxImageFileSize, @@ -408,6 +410,8 @@ const SettingsView = forwardRef(({ onDone, t checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, writeDelayMs, diffFuzzyThreshold, + maxNestingDepth, + autoFlattenOnLimit, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -873,6 +877,8 @@ const SettingsView = forwardRef(({ onDone, t maxDiagnosticMessages={maxDiagnosticMessages} writeDelayMs={writeDelayMs} diffFuzzyThreshold={diffFuzzyThreshold} + maxNestingDepth={maxNestingDepth} + autoFlattenOnLimit={autoFlattenOnLimit} includeCurrentTime={includeCurrentTime} includeCurrentCost={includeCurrentCost} maxGitStatusFiles={maxGitStatusFiles} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index 10d5d0fe4f..b7fae7802b 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -529,4 +529,61 @@ describe("ContextManagementSettings", () => { }) }) }) + + describe("taskTree settings", () => { + it("renders max nesting depth slider with default value when unset", () => { + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + expect(slider).toBeInTheDocument() + // Default is 2 (DEFAULT_MAX_NESTING_DEPTH) when the prop is unset. + expect(slider).toHaveValue("2") + }) + + it("renders max nesting depth slider with an explicit value", () => { + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + expect(slider).toHaveValue("4") + }) + + it("calls setCachedStateField when the max nesting depth slider changes", async () => { + const setCachedStateField = vi.fn() + render() + + const slider = screen.getByTestId("max-nesting-depth-slider") + fireEvent.change(slider, { target: { value: "3" } }) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("maxNestingDepth", 3) + }) + }) + + it("renders the auto-flatten checkbox checked by default when unset", () => { + render() + + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox") + expect(checkbox.querySelector("input")).toBeChecked() + }) + + it("renders the auto-flatten checkbox unchecked when explicitly false", () => { + render() + + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox") + expect(checkbox.querySelector("input")).not.toBeChecked() + }) + + it("calls setCachedStateField when the auto-flatten checkbox is toggled", async () => { + const setCachedStateField = vi.fn() + render() + + // Default (true) → clicking produces false. + const checkbox = screen.getByTestId("auto-flatten-on-limit-checkbox").querySelector("input")! + fireEvent.click(checkbox) + + await waitFor(() => { + expect(setCachedStateField).toHaveBeenCalledWith("autoFlattenOnLimit", false) + }) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index a5967792a1..4022e4c861 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -857,6 +857,16 @@ "description": "Lower thresholds make file edits more resilient to formatting and whitespace variations. A threshold of 100% requires an exact match." } }, + "taskTree": { + "maxNestingDepth": { + "label": "Maximum task nesting depth", + "description": "How many levels of subtasks can be nested (root = 0). A value of 0 disables delegation entirely — every new_task runs inline in the current conversation. Range: 0–5." + }, + "autoFlattenOnLimit": { + "label": "Auto-flatten subtasks at nesting limit", + "description": "When a subtask would exceed the maximum nesting depth, execute it inline in the current conversation instead of opening a new tab. When disabled, such requests are rejected so you continue working directly." + } + }, "condensingThreshold": { "label": "Condensing Trigger Threshold", "selectProfile": "Configure threshold for profile",