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
31 changes: 31 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2486,6 +2488,8 @@ export class ClineProvider
soundVolume,
writeDelayMs,
diffFuzzyThreshold,
maxNestingDepth,
autoFlattenOnLimit,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
terminalCommandDelay,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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]
Expand Down
17 changes: 17 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 45 additions & 1 deletion webview-ui/src/components/settings/ContextManagementSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -40,6 +40,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
maxDiagnosticMessages?: number
writeDelayMs: number
diffFuzzyThreshold?: number
maxNestingDepth?: number
autoFlattenOnLimit?: boolean
includeCurrentTime?: boolean
includeCurrentCost?: boolean
maxGitStatusFiles?: number
Expand All @@ -59,6 +61,8 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "maxDiagnosticMessages"
| "writeDelayMs"
| "diffFuzzyThreshold"
| "maxNestingDepth"
| "autoFlattenOnLimit"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
Expand All @@ -81,6 +85,8 @@ export const ContextManagementSettings = ({
maxDiagnosticMessages,
writeDelayMs,
diffFuzzyThreshold,
maxNestingDepth,
autoFlattenOnLimit,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
Expand Down Expand Up @@ -433,6 +439,44 @@ export const ContextManagementSettings = ({
</div>
</SearchableSetting>

<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>
<div className="flex items-center gap-2">
<Slider
min={0}
max={5}
step={1}
value={[maxNestingDepth ?? DEFAULT_MAX_NESTING_DEPTH]}
onValueChange={([value]) => setCachedStateField("maxNestingDepth", value)}
data-testid="max-nesting-depth-slider"
/>
<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")}
</div>
</SearchableSetting>

<SearchableSetting
settingId="context-auto-flatten-on-limit"
section="contextManagement"
label={t("settings: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")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:taskTree.autoFlattenOnLimit.description")}
</div>
</SearchableSetting>

<SearchableSetting
settingId="context-include-current-time"
section="contextManagement"
Expand Down
6 changes: 6 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalProfile,
writeDelayMs,
diffFuzzyThreshold,
maxNestingDepth,
autoFlattenOnLimit,
showRooIgnoredFiles,
enableSubfolderRules,
maxImageFileSize,
Expand Down Expand Up @@ -408,6 +410,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
checkpointTimeout: checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
writeDelayMs,
diffFuzzyThreshold,
maxNestingDepth,
autoFlattenOnLimit,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000,
terminalShellIntegrationDisabled,
terminalCommandDelay,
Expand Down Expand Up @@ -873,6 +877,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxDiagnosticMessages={maxDiagnosticMessages}
writeDelayMs={writeDelayMs}
diffFuzzyThreshold={diffFuzzyThreshold}
maxNestingDepth={maxNestingDepth}
autoFlattenOnLimit={autoFlattenOnLimit}
includeCurrentTime={includeCurrentTime}
includeCurrentCost={includeCurrentCost}
maxGitStatusFiles={maxGitStatusFiles}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,4 +529,61 @@ describe("ContextManagementSettings", () => {
})
})
})

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

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(<ContextManagementSettings {...defaultProps} maxNestingDepth={4} />)

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(<ContextManagementSettings {...defaultProps} setCachedStateField={setCachedStateField} />)

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(<ContextManagementSettings {...defaultProps} />)

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(<ContextManagementSettings {...defaultProps} autoFlattenOnLimit={false} />)

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(<ContextManagementSettings {...defaultProps} setCachedStateField={setCachedStateField} />)

// 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)
})
})
})
})
10 changes: 10 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading