From b344c7091d978722be29d04d4a542ee471e22e35 Mon Sep 17 00:00:00 2001 From: Sannidhya Sah Date: Sun, 8 Jun 2025 13:16:58 +0530 Subject: [PATCH 01/16] feat: add profile-specific context condensing thresholds --- packages/types/src/global-settings.ts | 2 + .../__tests__/sliding-window.spec.ts | 287 ++++++++++- src/core/sliding-window/index.ts | 19 +- src/core/task/Task.ts | 5 + src/core/webview/ClineProvider.ts | 6 + .../webview/__tests__/ClineProvider.spec.ts | 2 + src/core/webview/webviewMessageHandler.ts | 8 + src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 2 + .../settings/ContextManagementSettings.tsx | 38 ++ .../settings/ProfileThresholdManager.tsx | 143 ++++++ .../src/components/settings/SettingsView.tsx | 6 + .../ContextManagementSettings.spec.tsx | 113 +++++ .../ProfileThresholdManager.test.tsx | 469 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 7 + .../__tests__/ExtensionStateContext.spec.tsx | 2 + webview-ui/src/i18n/locales/en/settings.json | 10 + 17 files changed, 1119 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/settings/ProfileThresholdManager.tsx create mode 100644 webview-ui/src/components/settings/__tests__/ProfileThresholdManager.test.tsx diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 5b729a125f..47a504f52f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -103,6 +103,8 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), historyPreviewCollapsed: z.boolean().optional(), + profileSpecificThresholdsEnabled: z.boolean().optional(), + profileThresholds: z.record(z.string(), z.number()).optional(), }) export type GlobalSettings = z.infer diff --git a/src/core/sliding-window/__tests__/sliding-window.spec.ts b/src/core/sliding-window/__tests__/sliding-window.spec.ts index 0f41942547..3c6ca38ff8 100644 --- a/src/core/sliding-window/__tests__/sliding-window.spec.ts +++ b/src/core/sliding-window/__tests__/sliding-window.spec.ts @@ -19,7 +19,14 @@ import { // Create a mock ApiHandler for testing class MockApiHandler extends BaseProvider { createMessage(): any { - throw new Error("Method not implemented.") + // Mock implementation for testing - returns an async iterable stream + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { type: "text", text: "Mock summary content" } + yield { type: "usage", inputTokens: 100, outputTokens: 50 } + }, + } + return mockStream } getModel(): { id: string; info: ModelInfo } { @@ -265,6 +272,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Check the new return type @@ -304,6 +314,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result).toEqual({ @@ -337,6 +350,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) const result2 = await truncateConversationIfNeeded({ @@ -349,6 +365,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result1.messages).toEqual(result2.messages) @@ -368,6 +387,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) const result4 = await truncateConversationIfNeeded({ @@ -380,6 +402,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result3.messages).toEqual(result4.messages) @@ -414,6 +439,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(resultWithSmall).toEqual({ messages: messagesWithSmallContent, @@ -447,6 +475,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(resultWithLarge.messages).not.toEqual(messagesWithLargeContent) // Should truncate expect(resultWithLarge.summary).toBe("") @@ -473,6 +504,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(resultWithVeryLarge.messages).not.toEqual(messagesWithVeryLargeContent) // Should truncate expect(resultWithVeryLarge.summary).toBe("") @@ -509,6 +543,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result).toEqual({ messages: expectedResult, @@ -554,6 +591,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Verify summarizeConversation was called with the right parameters @@ -619,6 +659,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Verify summarizeConversation was called @@ -664,6 +707,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 50, // This shouldn't matter since autoCondenseContext is false systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Verify summarizeConversation was not called @@ -719,6 +765,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 50, // Set threshold to 50% - our tokens are at 60% systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Verify summarizeConversation was called with the right parameters @@ -769,6 +818,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 50, // Set threshold to 50% - our tokens are at 40% systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) // Verify summarizeConversation was not called @@ -787,6 +839,215 @@ describe("Sliding Window", () => { }) }) + /** + * Tests for profile-specific thresholds functionality + */ + describe("profile-specific thresholds", () => { + const createModelInfo = (contextWindow: number, maxTokens?: number): ModelInfo => ({ + contextWindow, + supportsPromptCache: true, + maxTokens, + }) + + const messages: ApiMessage[] = [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Second message" }, + { role: "user", content: "Third message" }, + { role: "assistant", content: "Fourth message" }, + { role: "user", content: "Fifth message" }, + ] + + /** + * Test that when profileSpecificThresholdsEnabled is true, + * a profile's specific threshold is correctly used instead of the global threshold + */ + it("should use profile-specific threshold when enabled and profile has specific threshold", async () => { + const modelInfo = createModelInfo(100000, 30000) + const profileThresholds = { + "test-profile": 60, // Profile-specific threshold of 60% + } + const currentProfileId = "test-profile" + const contextWindow = modelInfo.contextWindow + + // Set tokens to 65% of context window - above profile threshold (60%) but below global default (100%) + const totalTokens = Math.floor(contextWindow * 0.65) // 65000 tokens + + // Create messages with very small content in the last one to avoid token overflow + const messagesWithSmallContent = [ + ...messages.slice(0, -1), + { ...messages[messages.length - 1], content: "" }, + ] + + // Mock the summarizeConversation function + const mockSummary = "Profile-specific threshold summary" + const mockCost = 0.03 + const mockSummarizeResponse: condenseModule.SummarizeResponse = { + messages: [ + { role: "user", content: "First message" }, + { role: "assistant", content: mockSummary, isSummary: true }, + { role: "user", content: "Last message" }, + ], + summary: mockSummary, + cost: mockCost, + newContextTokens: 100, + } + + const summarizeSpy = jest + .spyOn(condenseModule, "summarizeConversation") + .mockResolvedValue(mockSummarizeResponse) + + const result = await truncateConversationIfNeeded({ + messages: messagesWithSmallContent, + totalTokens, + contextWindow, + maxTokens: modelInfo.maxTokens, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 100, // Global threshold of 100% + systemPrompt: "System prompt", + taskId, + profileSpecificThresholdsEnabled: true, + profileThresholds, + currentProfileId, + }) + + // Should use summarization because 65% > 60% (profile threshold) + expect(summarizeSpy).toHaveBeenCalled() + expect(result).toMatchObject({ + messages: mockSummarizeResponse.messages, + summary: mockSummary, + cost: mockCost, + prevContextTokens: totalTokens, + }) + + // Clean up + summarizeSpy.mockRestore() + }) + + /** + * Test that when a profile's threshold is set to -1, + * the function correctly falls back to using the global autoCondenseContextPercent + */ + it("should fall back to global threshold when profile threshold is -1", async () => { + const modelInfo = createModelInfo(100000, 30000) + const profileThresholds = { + "test-profile": -1, // Profile threshold set to -1 (use global) + } + const currentProfileId = "test-profile" + const contextWindow = modelInfo.contextWindow + + // Set tokens to 80% of context window - above global threshold (75%) but would be below if profile had its own + const totalTokens = Math.floor(contextWindow * 0.8) // 80000 tokens + + // Create messages with very small content in the last one to avoid token overflow + const messagesWithSmallContent = [ + ...messages.slice(0, -1), + { ...messages[messages.length - 1], content: "" }, + ] + + // Mock the summarizeConversation function + const mockSummary = "Global threshold fallback summary" + const mockCost = 0.04 + const mockSummarizeResponse: condenseModule.SummarizeResponse = { + messages: [ + { role: "user", content: "First message" }, + { role: "assistant", content: mockSummary, isSummary: true }, + { role: "user", content: "Last message" }, + ], + summary: mockSummary, + cost: mockCost, + newContextTokens: 120, + } + + const summarizeSpy = jest + .spyOn(condenseModule, "summarizeConversation") + .mockResolvedValue(mockSummarizeResponse) + + const result = await truncateConversationIfNeeded({ + messages: messagesWithSmallContent, + totalTokens, + contextWindow, + maxTokens: modelInfo.maxTokens, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 75, // Global threshold of 75% + systemPrompt: "System prompt", + taskId, + profileSpecificThresholdsEnabled: true, + profileThresholds, + currentProfileId, + }) + + // Should use summarization because 80% > 75% (global threshold, since profile is -1) + expect(summarizeSpy).toHaveBeenCalled() + expect(result).toMatchObject({ + messages: mockSummarizeResponse.messages, + summary: mockSummary, + cost: mockCost, + prevContextTokens: totalTokens, + }) + + // Clean up + summarizeSpy.mockRestore() + }) + + /** + * Test that when a profile does not have a specific threshold defined, + * the function correctly falls back to the global default + */ + it("should fall back to global threshold when profile has no specific threshold", async () => { + const modelInfo = createModelInfo(100000, 30000) + const profileThresholds = { + "other-profile": 50, // Different profile has a threshold + } + const currentProfileId = "test-profile" // This profile is not in profileThresholds + const contextWindow = modelInfo.contextWindow + + // Calculate allowedTokens: contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + // allowedTokens = 100000 * 0.9 - 30000 = 60000 + // Set tokens to be below both the global threshold (80%) and allowedTokens + const totalTokens = 50000 // 50% of context window, well below 60000 allowedTokens and 80% threshold + + // Create messages with very small content in the last one to avoid token overflow + const messagesWithSmallContent = [ + ...messages.slice(0, -1), + { ...messages[messages.length - 1], content: "" }, + ] + + // Reset any previous mock calls + jest.clearAllMocks() + const summarizeSpy = jest.spyOn(condenseModule, "summarizeConversation") + + const result = await truncateConversationIfNeeded({ + messages: messagesWithSmallContent, + totalTokens, + contextWindow, + maxTokens: modelInfo.maxTokens, + apiHandler: mockApiHandler, + autoCondenseContext: true, + autoCondenseContextPercent: 80, // Global threshold of 80% + systemPrompt: "System prompt", + taskId, + profileSpecificThresholdsEnabled: true, + profileThresholds, + currentProfileId, + }) + + // Should NOT use summarization because 50% < 80% (global threshold, since profile has no specific threshold) + // and totalTokens (50000) < allowedTokens (60000) + expect(summarizeSpy).not.toHaveBeenCalled() + expect(result).toEqual({ + messages: messagesWithSmallContent, + summary: "", + cost: 0, + prevContextTokens: totalTokens, + }) + + // Clean up + summarizeSpy.mockRestore() + }) + }) + /** * Tests for the getMaxTokens function (private but tested through truncateConversationIfNeeded) */ @@ -829,6 +1090,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result1).toEqual({ messages: messagesWithSmallContent, @@ -848,6 +1112,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result2.messages).not.toEqual(messagesWithSmallContent) expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction @@ -878,6 +1145,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result1).toEqual({ messages: messagesWithSmallContent, @@ -897,6 +1167,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result2.messages).not.toEqual(messagesWithSmallContent) expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction @@ -926,6 +1199,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result1.messages).toEqual(messagesWithSmallContent) @@ -940,6 +1216,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result2).not.toEqual(messagesWithSmallContent) expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction @@ -967,6 +1246,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result1.messages).toEqual(messagesWithSmallContent) @@ -981,6 +1263,9 @@ describe("Sliding Window", () => { autoCondenseContextPercent: 100, systemPrompt: "System prompt", taskId, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, + currentProfileId: "default", }) expect(result2).not.toEqual(messagesWithSmallContent) expect(result2.messages.length).toBe(3) // Truncated with 0.5 fraction diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index dc9eaf718d..9d0a064307 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -74,6 +74,9 @@ type TruncateOptions = { taskId: string customCondensingPrompt?: string condensingApiHandler?: ApiHandler + profileSpecificThresholdsEnabled: boolean + profileThresholds: Record + currentProfileId: string } type TruncateResponse = SummarizeResponse & { prevContextTokens: number } @@ -97,6 +100,9 @@ export async function truncateConversationIfNeeded({ taskId, customCondensingPrompt, condensingApiHandler, + profileSpecificThresholdsEnabled, + profileThresholds, + currentProfileId, }: TruncateOptions): Promise { let error: string | undefined let cost = 0 @@ -117,9 +123,20 @@ export async function truncateConversationIfNeeded({ // Truncate if we're within TOKEN_BUFFER_PERCENTAGE of the context window const allowedTokens = contextWindow * (1 - TOKEN_BUFFER_PERCENTAGE) - reservedTokens + // Determine the effective threshold to use + let effectiveThreshold = autoCondenseContextPercent + if (profileSpecificThresholdsEnabled) { + const profileThreshold = profileThresholds[currentProfileId] + if (profileThreshold !== undefined) { + // Special case: if the value is -1, use the global autoCondenseContextPercent + effectiveThreshold = profileThreshold === -1 ? autoCondenseContextPercent : profileThreshold + } + // If no specific threshold is found for the profile, fall back to global setting + } + if (autoCondenseContext) { const contextPercent = (100 * prevContextTokens) / contextWindow - if (contextPercent >= autoCondenseContextPercent || prevContextTokens > allowedTokens) { + if (contextPercent >= effectiveThreshold || prevContextTokens > allowedTokens) { // Attempt to intelligently condense the context const result = await summarizeConversation( messages, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 6f6f2d684a..8c97613d1c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1643,6 +1643,8 @@ export class Task extends EventEmitter { mode, autoCondenseContext = true, autoCondenseContextPercent = 100, + profileSpecificThresholdsEnabled = false, + profileThresholds = {}, } = state ?? {} // Get condensing configuration for automatic triggers @@ -1719,6 +1721,9 @@ export class Task extends EventEmitter { taskId: this.taskId, customCondensingPrompt, condensingApiHandler, + profileSpecificThresholdsEnabled, + profileThresholds, + currentProfileId: state?.currentApiConfigName || "default", }) if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 49fd0c1258..28b4f1a888 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1376,6 +1376,8 @@ export class ClineProvider customCondensingPrompt, codebaseIndexConfig, codebaseIndexModels, + profileSpecificThresholdsEnabled, + profileThresholds, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1483,6 +1485,8 @@ export class ClineProvider codebaseIndexEmbedderModelId: "", }, mdmCompliant: this.checkMdmCompliance(), + profileSpecificThresholdsEnabled: profileSpecificThresholdsEnabled ?? false, + profileThresholds: profileThresholds ?? {}, } } @@ -1632,6 +1636,8 @@ export class ClineProvider codebaseIndexEmbedderBaseUrl: "", codebaseIndexEmbedderModelId: "", }, + profileSpecificThresholdsEnabled: stateValues.profileSpecificThresholdsEnabled ?? false, + profileThresholds: stateValues.profileThresholds ?? {}, } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index de4f34a12a..29dc72fea4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -522,6 +522,8 @@ describe("ClineProvider", () => { autoCondenseContextPercent: 100, cloudIsAuthenticated: false, sharingEnabled: false, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, } const message: ExtensionMessage = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c6ef3c839c..c4570d0bfa 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1580,5 +1580,13 @@ export const webviewMessageHandler = async ( } break } + case "profileSpecificThresholdsEnabled": + await updateGlobalState("profileSpecificThresholdsEnabled", message.bool) + await provider.postStateToWebview() + break + case "profileThresholds": + await updateGlobalState("profileThresholds", message.values as Record) + await provider.postStateToWebview() + break } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index a515a2d004..f1d18e0cfd 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -259,6 +259,8 @@ export type ExtensionState = Pick< autoCondenseContextPercent: number marketplaceItems?: MarketplaceItem[] marketplaceInstalledMetadata?: { project: Record; global: Record } + profileSpecificThresholdsEnabled: boolean + profileThresholds: Record } export interface ClineSayTool { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 9ea0e192eb..97846955ce 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -169,6 +169,8 @@ export interface WebviewMessage { | "marketplaceInstallResult" | "fetchMarketplaceData" | "switchTab" + | "profileSpecificThresholdsEnabled" + | "profileThresholds" text?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" disabled?: boolean diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index 548020159e..247e744360 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -9,6 +9,7 @@ import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, Select import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { ProfileThresholdManager } from "./ProfileThresholdManager" import { vscode } from "@/utils/vscode" const SUMMARY_PROMPT = `\ @@ -62,6 +63,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { showRooIgnoredFiles?: boolean maxReadFileLine?: number maxConcurrentFileReads?: number + profileSpecificThresholdsEnabled: boolean + profileThresholds: Record setCachedStateField: SetCachedStateField< | "autoCondenseContext" | "autoCondenseContextPercent" @@ -72,6 +75,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "showRooIgnoredFiles" | "maxReadFileLine" | "maxConcurrentFileReads" + | "profileSpecificThresholdsEnabled" + | "profileThresholds" > } @@ -87,6 +92,8 @@ export const ContextManagementSettings = ({ setCachedStateField, maxReadFileLine, maxConcurrentFileReads, + profileSpecificThresholdsEnabled, + profileThresholds, className, ...props }: ContextManagementSettingsProps) => { @@ -328,6 +335,37 @@ export const ContextManagementSettings = ({ )} + +
+ { + const isChecked = e.target.checked + setCachedStateField("profileSpecificThresholdsEnabled", isChecked) + vscode.postMessage({ + type: "profileSpecificThresholdsEnabled", + bool: isChecked, + }) + }} + data-testid="profile-specific-thresholds-checkbox"> + + {t("settings:contextManagement.profileThresholds.enabled") || + "Enable Profile-Specific Thresholds"} + + +
+ {t("settings:contextManagement.profileThresholds.description") || + "Configure different context condensing thresholds for specific API profiles."} +
+ {profileSpecificThresholdsEnabled && ( + setCachedStateField("profileThresholds", thresholds)} + /> + )} +
) } diff --git a/webview-ui/src/components/settings/ProfileThresholdManager.tsx b/webview-ui/src/components/settings/ProfileThresholdManager.tsx new file mode 100644 index 0000000000..7e6310b908 --- /dev/null +++ b/webview-ui/src/components/settings/ProfileThresholdManager.tsx @@ -0,0 +1,143 @@ +import { VSCodeButton, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import React, { useState } from "react" +import { useTranslation } from "react-i18next" +import { ProviderSettingsEntry } from "@roo-code/types" + +interface ProfileThresholdManagerProps { + listApiConfigMeta: ProviderSettingsEntry[] + profileThresholds: Record + defaultThreshold: number + onUpdateThresholds: (thresholds: Record) => void +} + +export const ProfileThresholdManager: React.FC = ({ + listApiConfigMeta, + profileThresholds, + defaultThreshold, + onUpdateThresholds, +}) => { + const { t } = useTranslation() + const [selectedProfileId, setSelectedProfileId] = useState("") + const [thresholdInput, setThresholdInput] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + const handleProfileChange = (profileId: string) => { + setSelectedProfileId(profileId) + const existingThreshold = profileThresholds[profileId] + if (existingThreshold === -1) { + setThresholdInput("-1") + } else if (existingThreshold !== undefined) { + setThresholdInput(existingThreshold.toString()) + } else { + setThresholdInput("") + } + setHasUnsavedChanges(false) + } + + const handleThresholdChange = (value: string) => { + setThresholdInput(value) + setHasUnsavedChanges(true) + } + + const handleSave = () => { + if (selectedProfileId && thresholdInput !== "") { + const numValue = parseInt(thresholdInput, 10) + if (!isNaN(numValue)) { + const newThresholds = { + ...profileThresholds, + [selectedProfileId]: numValue, + } + onUpdateThresholds(newThresholds) + setSelectedProfileId("") + setThresholdInput("") + setHasUnsavedChanges(false) + } + } + } + + const handleRemove = (profileId: string) => { + const newThresholds = { ...profileThresholds } + delete newThresholds[profileId] + onUpdateThresholds(newThresholds) + } + + const formatThresholdDisplay = (threshold: number): string => { + if (threshold === -1) { + return ( + t("settings:contextManagement.profileThresholds.defaultThreshold") || `Default (${defaultThreshold}%)` + ) + } + return `${threshold}%` + } + + return ( +
+
+ +
+ handleProfileChange(e.target.value)} + className="flex-1"> + + {t("settings:contextManagement.profileThresholds.selectProfile") || "Select a profile"} + + {listApiConfigMeta.map((profile) => ( + + {profile.name} + + ))} + + handleThresholdChange(e.target.value)} + placeholder="%" + className="w-20 bg-vscode-input-background text-vscode-input-foreground p-1 rounded border border-vscode-input-border" + disabled={!selectedProfileId} + /> + + {t("settings:common.save") || "Save"} + +
+
+ {t("settings:contextManagement.profileThresholds.infoText") || + "ℹ️ Enter -1 to use the default threshold"} +
+
+ {Object.keys(profileThresholds).length > 0 && ( +
+

+ {t("settings:contextManagement.profileThresholds.configuredProfiles") || "Configured Profiles:"} +

+
    + {Object.entries(profileThresholds).map(([profileId, threshold]) => { + const profile = listApiConfigMeta.find((p) => p.id === profileId) + return ( +
  • + + • {profile?.name || profileId}: {formatThresholdDisplay(threshold)} + + handleRemove(profileId)} + aria-label={ + t("settings:contextManagement.profileThresholds.removeAriaLabel") || + `Remove threshold for ${profile?.name}` + }> + + +
  • + ) + })} +
+
+ )} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5a330c8996..9050286e00 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -172,6 +172,8 @@ const SettingsView = forwardRef(({ onDone, t codebaseIndexConfig, codebaseIndexModels, customSupportPrompts, + profileSpecificThresholdsEnabled, + profileThresholds, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -315,6 +317,8 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "codebaseIndexConfig", values: codebaseIndexConfig }) + vscode.postMessage({ type: "profileSpecificThresholdsEnabled", bool: profileSpecificThresholdsEnabled }) + vscode.postMessage({ type: "profileThresholds", values: profileThresholds }) setChangeDetected(false) } } @@ -644,6 +648,8 @@ const SettingsView = forwardRef(({ onDone, t showRooIgnoredFiles={showRooIgnoredFiles} maxReadFileLine={maxReadFileLine} maxConcurrentFileReads={maxConcurrentFileReads} + profileSpecificThresholdsEnabled={profileSpecificThresholdsEnabled} + profileThresholds={profileThresholds} setCachedStateField={setCachedStateField} /> )} diff --git a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx index d1c8f64f57..3a576f36b6 100644 --- a/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx @@ -49,6 +49,8 @@ describe("ContextManagementSettings", () => { maxWorkspaceFiles: 200, showRooIgnoredFiles: false, setCachedStateField: vitest.fn(), + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, } beforeEach(() => { @@ -529,4 +531,115 @@ describe("ContextManagementSettings", () => { expect(screen.getByText("settings:contextManagement.rooignore.label")).toBeInTheDocument() }) }) + + /** + * Tests for profile-specific thresholds functionality + */ + describe("Profile-specific thresholds", () => { + it("renders ProfileThresholdManager when profile-specific thresholds are enabled", () => { + const propsWithProfileThresholds = { + ...defaultProps, + profileSpecificThresholdsEnabled: true, + profileThresholds: { + "profile-1": 60, + }, + listApiConfigMeta: [ + { id: "profile-1", name: "Profile 1" }, + { id: "profile-2", name: "Profile 2" }, + ], + } + + render() + + // Should render the profile-specific thresholds checkbox + const profileThresholdsCheckbox = screen.getByTestId("profile-specific-thresholds-checkbox") + expect(profileThresholdsCheckbox).toBeInTheDocument() + expect(profileThresholdsCheckbox).toBeChecked() + + // Should render the ProfileThresholdManager component - verify it's present + // Look for the threshold input which is a reliable indicator the component rendered + expect(screen.getByPlaceholderText("%")).toBeInTheDocument() + + // Verify the component renders profile options in the dropdown + expect(screen.getByText("Profile 1")).toBeInTheDocument() + expect(screen.getByText("Profile 2")).toBeInTheDocument() + }) + + it("does not render ProfileThresholdManager when profile-specific thresholds are disabled", () => { + const propsWithoutProfileThresholds = { + ...defaultProps, + profileSpecificThresholdsEnabled: false, + listApiConfigMeta: [ + { id: "profile-1", name: "Profile 1" }, + { id: "profile-2", name: "Profile 2" }, + ], + } + + render() + + // Should render the profile-specific thresholds checkbox + const profileThresholdsCheckbox = screen.getByTestId("profile-specific-thresholds-checkbox") + expect(profileThresholdsCheckbox).toBeInTheDocument() + expect(profileThresholdsCheckbox).not.toBeChecked() + + // Should NOT render the ProfileThresholdManager component + expect(screen.queryByTestId("profile-dropdown")).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText("%")).not.toBeInTheDocument() + }) + + it("toggles ProfileThresholdManager visibility when checkbox is clicked", () => { + const mockSetCachedStateField = vitest.fn() + const props = { + ...defaultProps, + profileSpecificThresholdsEnabled: false, + setCachedStateField: mockSetCachedStateField, + listApiConfigMeta: [{ id: "profile-1", name: "Profile 1" }], + } + + const { rerender } = render() + + const profileThresholdsCheckbox = screen.getByTestId("profile-specific-thresholds-checkbox") + + // Initially should not show ProfileThresholdManager + expect( + screen.queryByText("settings:contextManagement.profileThresholds.configureLabel"), + ).not.toBeInTheDocument() + + // Click to enable + fireEvent.click(profileThresholdsCheckbox) + expect(mockSetCachedStateField).toHaveBeenCalledWith("profileSpecificThresholdsEnabled", true) + + // Re-render with updated props to simulate the state change + rerender() + + // Should now show ProfileThresholdManager - verify threshold input is present + expect(screen.getByPlaceholderText("%")).toBeInTheDocument() + }) + + it("calls setCachedStateField when profile thresholds are updated", () => { + const mockSetCachedStateField = vitest.fn() + const propsWithProfileThresholds = { + ...defaultProps, + profileSpecificThresholdsEnabled: true, + profileThresholds: {}, + setCachedStateField: mockSetCachedStateField, + listApiConfigMeta: [{ id: "profile-1", name: "Profile 1" }], + } + + render() + + // Find the threshold input element within ProfileThresholdManager + const thresholdInput = screen.getByPlaceholderText("%") + + // Verify the input is initially disabled (no profile selected) + expect(thresholdInput).toBeDisabled() + + // Test basic input functionality + fireEvent.change(thresholdInput, { target: { value: "65" } }) + + // Note: VSCodeDropdown interaction testing is complex with the VSCode UI toolkit, + // so we focus on verifying the component renders and basic input functionality works + // The actual dropdown interaction and save functionality is tested in ProfileThresholdManager.test.tsx + }) + }) }) diff --git a/webview-ui/src/components/settings/__tests__/ProfileThresholdManager.test.tsx b/webview-ui/src/components/settings/__tests__/ProfileThresholdManager.test.tsx new file mode 100644 index 0000000000..e87836f6a9 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/ProfileThresholdManager.test.tsx @@ -0,0 +1,469 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react" +import { ProfileThresholdManager } from "../ProfileThresholdManager" +import type { ProviderSettingsEntry } from "@roo-code/types" + +// Mock translation hook to return the key as the translation +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock VSCode UI components +jest.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, disabled, appearance, "aria-label": ariaLabel }: any) => ( + + ), + VSCodeDropdown: ({ children, value, onChange, className }: any) => ( + + ), + VSCodeOption: ({ children, value }: any) => , +})) + +describe("ProfileThresholdManager", () => { + const mockListApiConfigMeta: ProviderSettingsEntry[] = [ + { id: "profile-1", name: "Profile 1" }, + { id: "profile-2", name: "Profile 2" }, + { id: "profile-3", name: "Profile 3" }, + ] + + const defaultProps = { + listApiConfigMeta: mockListApiConfigMeta, + profileThresholds: {}, + defaultThreshold: 75, + onUpdateThresholds: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + /** + * Test basic rendering functionality + */ + describe("Rendering", () => { + it("renders correctly with empty profile thresholds", () => { + render() + + // Should render the configuration section + expect(screen.getByText("settings:contextManagement.profileThresholds.configureLabel")).toBeInTheDocument() + + // Should render the dropdown with default option + const dropdown = screen.getByDisplayValue("") // VSCodeDropdown with empty value + expect(dropdown).toBeInTheDocument() + expect(screen.getByText("settings:contextManagement.profileThresholds.selectProfile")).toBeInTheDocument() + + // Should render all profile options + expect(screen.getByText("Profile 1")).toBeInTheDocument() + expect(screen.getByText("Profile 2")).toBeInTheDocument() + expect(screen.getByText("Profile 3")).toBeInTheDocument() + + // Should render threshold input + const thresholdInput = screen.getByPlaceholderText("%") + expect(thresholdInput).toBeInTheDocument() + expect(thresholdInput).toBeDisabled() // Should be disabled when no profile selected + + // Should render save button + const saveButton = screen.getByText("settings:common.save") + expect(saveButton).toBeInTheDocument() + expect(saveButton).toBeDisabled() // Should be disabled initially + + // Should render info text + expect(screen.getByText("settings:contextManagement.profileThresholds.infoText")).toBeInTheDocument() + + // Should not render configured profiles section when empty + expect( + screen.queryByText("settings:contextManagement.profileThresholds.configuredProfiles"), + ).not.toBeInTheDocument() + }) + + it("renders configured profiles list when profile thresholds exist", () => { + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "profile-2": -1, + "profile-3": 80, + }, + } + + render() + + // Should render configured profiles section + expect( + screen.getByText("settings:contextManagement.profileThresholds.configuredProfiles"), + ).toBeInTheDocument() + + // Should render each configured profile + expect(screen.getByText("• Profile 1: 60%")).toBeInTheDocument() + expect( + screen.getByText("• Profile 2: settings:contextManagement.profileThresholds.defaultThreshold"), + ).toBeInTheDocument() + expect(screen.getByText("• Profile 3: 80%")).toBeInTheDocument() + + // Should render remove buttons for each profile + const removeButtons = screen.getAllByLabelText( + /settings:contextManagement.profileThresholds.removeAriaLabel/, + ) + expect(removeButtons).toHaveLength(3) + }) + }) + + /** + * Test profile selection and threshold input functionality + */ + describe("Profile Selection and Input", () => { + it("renders with correct initial state", () => { + render() + + const thresholdInput = screen.getByPlaceholderText("%") as HTMLInputElement + const saveButton = screen.getByText("settings:common.save") + + // Initially disabled + expect(thresholdInput).toBeDisabled() + expect(saveButton).toBeDisabled() + + // Note: VSCodeDropdown interaction testing is complex with the VSCode UI toolkit + // The actual dropdown selection logic is tested through unit tests of the component methods + }) + + it("renders save button in disabled state initially", () => { + render() + + const saveButton = screen.getByText("settings:common.save") + + // Save button should be disabled initially (no profile selected, no input) + expect(saveButton).toBeDisabled() + }) + + it("displays existing thresholds correctly in the configured profiles list", () => { + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "profile-2": -1, + }, + } + + render() + + // Should show configured profiles section + expect( + screen.getByText("settings:contextManagement.profileThresholds.configuredProfiles"), + ).toBeInTheDocument() + + // Should display the threshold values correctly + expect(screen.getByText("• Profile 1: 60%")).toBeInTheDocument() + expect( + screen.getByText("• Profile 2: settings:contextManagement.profileThresholds.defaultThreshold"), + ).toBeInTheDocument() + + // Should have remove buttons for each configured profile + const removeButtons = screen.getAllByLabelText( + /settings:contextManagement.profileThresholds.removeAriaLabel/, + ) + expect(removeButtons).toHaveLength(2) + }) + }) + + /** + * Test save functionality + */ + describe("Save Functionality", () => { + it("calls onUpdateThresholds with correct data when save is clicked", () => { + const mockOnUpdateThresholds = jest.fn() + const props = { + ...defaultProps, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const thresholdInput = screen.getByPlaceholderText("%") + const saveButton = screen.getByText("settings:common.save") + + // Select profile and enter threshold + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + fireEvent.change(thresholdInput, { target: { value: "65" } }) + fireEvent.click(saveButton) + + // Should call onUpdateThresholds with new threshold + expect(mockOnUpdateThresholds).toHaveBeenCalledWith({ + "profile-1": 65, + }) + }) + + it("merges new threshold with existing thresholds", () => { + const mockOnUpdateThresholds = jest.fn() + const propsWithExisting = { + ...defaultProps, + profileThresholds: { + "profile-2": 70, + }, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const thresholdInput = screen.getByPlaceholderText("%") + const saveButton = screen.getByText("settings:common.save") + + // Add threshold for different profile + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + fireEvent.change(thresholdInput, { target: { value: "65" } }) + fireEvent.click(saveButton) + + // Should merge with existing thresholds + expect(mockOnUpdateThresholds).toHaveBeenCalledWith({ + "profile-2": 70, + "profile-1": 65, + }) + }) + + it("resets form state after successful save", () => { + render() + + const dropdown = screen.getByTestId("profile-dropdown") as HTMLSelectElement + const thresholdInput = screen.getByPlaceholderText("%") as HTMLInputElement + const saveButton = screen.getByText("settings:common.save") + + // Select profile and enter threshold + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + fireEvent.change(thresholdInput, { target: { value: "65" } }) + fireEvent.click(saveButton) + + // Form should be reset + expect(dropdown.value).toBe("") + expect(thresholdInput.value).toBe("") + expect(thresholdInput).toBeDisabled() + expect(saveButton).toBeDisabled() + }) + + it("handles -1 threshold value correctly", () => { + const mockOnUpdateThresholds = jest.fn() + const props = { + ...defaultProps, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const thresholdInput = screen.getByPlaceholderText("%") + const saveButton = screen.getByText("settings:common.save") + + // Enter -1 threshold + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + fireEvent.change(thresholdInput, { target: { value: "-1" } }) + fireEvent.click(saveButton) + + // Should save -1 correctly + expect(mockOnUpdateThresholds).toHaveBeenCalledWith({ + "profile-1": -1, + }) + }) + + it("does not save invalid threshold values", () => { + const mockOnUpdateThresholds = jest.fn() + const props = { + ...defaultProps, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const thresholdInput = screen.getByPlaceholderText("%") + const saveButton = screen.getByText("settings:common.save") + + // Try to save invalid threshold + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + fireEvent.change(thresholdInput, { target: { value: "invalid" } }) + fireEvent.click(saveButton) + + // Should not call onUpdateThresholds + expect(mockOnUpdateThresholds).not.toHaveBeenCalled() + }) + }) + + /** + * Test remove functionality + */ + describe("Remove Functionality", () => { + it("calls onUpdateThresholds to remove profile threshold when remove button is clicked", () => { + const mockOnUpdateThresholds = jest.fn() + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "profile-2": 70, + }, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + // Find and click remove button for profile-1 + const removeButtons = screen.getAllByLabelText( + /settings:contextManagement.profileThresholds.removeAriaLabel/, + ) + fireEvent.click(removeButtons[0]) // First remove button (profile-1) + + // Should call onUpdateThresholds with profile-1 removed + expect(mockOnUpdateThresholds).toHaveBeenCalledWith({ + "profile-2": 70, + }) + }) + + it("removes the correct profile when multiple profiles exist", () => { + const mockOnUpdateThresholds = jest.fn() + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "profile-2": 70, + "profile-3": 80, + }, + onUpdateThresholds: mockOnUpdateThresholds, + } + + render() + + // Find and click remove button for profile-2 (middle one) + const removeButtons = screen.getAllByLabelText( + /settings:contextManagement.profileThresholds.removeAriaLabel/, + ) + fireEvent.click(removeButtons[1]) // Second remove button (profile-2) + + // Should call onUpdateThresholds with profile-2 removed + expect(mockOnUpdateThresholds).toHaveBeenCalledWith({ + "profile-1": 60, + "profile-3": 80, + }) + }) + }) + + /** + * Test threshold display formatting + */ + describe("Threshold Display Formatting", () => { + it("displays percentage values correctly", () => { + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "profile-3": 80, + }, + } + + render() + + expect(screen.getByText("• Profile 1: 60%")).toBeInTheDocument() + expect(screen.getByText("• Profile 3: 80%")).toBeInTheDocument() + }) + + it("displays default threshold message for -1 values", () => { + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-2": -1, + }, + } + + render() + + expect( + screen.getByText("• Profile 2: settings:contextManagement.profileThresholds.defaultThreshold"), + ).toBeInTheDocument() + }) + + it("handles profiles that no longer exist in listApiConfigMeta", () => { + const propsWithOrphanedThreshold = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + "deleted-profile": 70, // This profile no longer exists in listApiConfigMeta + }, + } + + render() + + // Should display profile name for existing profile + expect(screen.getByText("• Profile 1: 60%")).toBeInTheDocument() + // Should display profile ID for non-existent profile + expect(screen.getByText("• deleted-profile: 70%")).toBeInTheDocument() + }) + }) + + /** + * Test edge cases and validation + */ + describe("Edge Cases and Validation", () => { + it("handles empty listApiConfigMeta gracefully", () => { + const propsWithEmptyList = { + ...defaultProps, + listApiConfigMeta: [], + } + + expect(() => { + render() + }).not.toThrow() + + // Should still render the dropdown with just the default option + expect(screen.getByText("settings:contextManagement.profileThresholds.selectProfile")).toBeInTheDocument() + }) + + it("disables save button when no profile is selected", () => { + render() + + const thresholdInput = screen.getByPlaceholderText("%") + const saveButton = screen.getByText("settings:common.save") + + // Try to enter threshold without selecting profile + fireEvent.change(thresholdInput, { target: { value: "65" } }) + + // Save button should remain disabled + expect(saveButton).toBeDisabled() + }) + + it("disables save button when threshold input is empty", () => { + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const saveButton = screen.getByText("settings:common.save") + + // Select profile but don't enter threshold + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + + // Save button should remain disabled + expect(saveButton).toBeDisabled() + }) + + it("disables save button when there are no unsaved changes", () => { + const propsWithThresholds = { + ...defaultProps, + profileThresholds: { + "profile-1": 60, + }, + } + + render() + + const dropdown = screen.getByTestId("profile-dropdown") + const saveButton = screen.getByText("settings:common.save") + + // Select profile with existing threshold (no changes) + fireEvent.change(dropdown, { target: { value: "profile-1" } }) + + // Save button should be disabled (no unsaved changes) + expect(saveButton).toBeDisabled() + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e0f6e0d3ad..1835f8bb4b 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -44,6 +44,8 @@ export interface ExtensionStateContextType extends ExtensionState { setCustomCondensingPrompt: (value: string) => void marketplaceItems?: any[] marketplaceInstalledMetadata?: MarketplaceInstalledMetadata + profileThresholds: Record + setProfileThresholds: (value: Record) => void setApiConfiguration: (config: ProviderSettings) => void setCustomInstructions: (value?: string) => void setAlwaysAllowReadOnly: (value: boolean) => void @@ -201,6 +203,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode organizationAllowList: ORGANIZATION_ALLOW_ALL, autoCondenseContext: true, autoCondenseContextPercent: 100, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, codebaseIndexConfig: { codebaseIndexEnabled: false, codebaseIndexQdrantUrl: "http://localhost:6333", @@ -429,6 +433,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCondensingApiConfigId: (value) => setState((prevState) => ({ ...prevState, condensingApiConfigId: value })), setCustomCondensingPrompt: (value) => setState((prevState) => ({ ...prevState, customCondensingPrompt: value })), + setProfileSpecificThresholdsEnabled: (value) => + setState((prevState) => ({ ...prevState, profileSpecificThresholdsEnabled: value })), + setProfileThresholds: (value) => setState((prevState) => ({ ...prevState, profileThresholds: value })), } return {children} diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index d426e59252..fffffceae5 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -207,6 +207,8 @@ describe("mergeExtensionState", () => { autoCondenseContextPercent: 100, cloudIsAuthenticated: false, sharingEnabled: false, + profileSpecificThresholdsEnabled: false, + profileThresholds: {}, } const prevState: ExtensionState = { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b8e51afc50..fa598cc8f9 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -393,6 +393,16 @@ "description": "Roo reads this number of lines when the model omits start/end values. If this number is less than the file's total, Roo generates a line number index of code definitions. Special cases: -1 instructs Roo to read the entire file (without indexing), and 0 instructs it to read no lines and provides line indexes only for minimal context. Lower values minimize initial context usage, enabling precise subsequent line-range reads. Explicit start/end requests are not limited by this setting.", "lines": "lines", "always_full_read": "Always read entire file" + }, + "profileThresholds": { + "enabled": "Enable profile-specific thresholds", + "description": "Allows setting different context condensing thresholds for each API configuration profile.", + "configureLabel": "Configure threshold for profile:", + "selectProfile": "Select a profile", + "infoText": "ℹ️ Enter -1 to use the default threshold", + "configuredProfiles": "Configured Profiles:", + "defaultThreshold": "Default ({{defaultThreshold}}%)", + "removeAriaLabel": "Remove threshold for {{profileName}}" } }, "terminal": { From d4e6e335a85575a64cb3b71d0010b77e1ec28ed4 Mon Sep 17 00:00:00 2001 From: Sannidhya Sah Date: Sun, 8 Jun 2025 20:12:51 +0530 Subject: [PATCH 02/16] refactor: improve ProfileThresholdManager UI layout and positioning - Remove emoji from info text and use codicon instead - Restructure layout to vertical stack for better spacing - Position Save button on far right using justify-between - Apply consistent styling with other settings sections - Update tests to match hardcoded info text --- .../settings/ProfileThresholdManager.tsx | 51 ++++++++++--------- .../ProfileThresholdManager.test.tsx | 5 +- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/webview-ui/src/components/settings/ProfileThresholdManager.tsx b/webview-ui/src/components/settings/ProfileThresholdManager.tsx index 7e6310b908..d36d9d0602 100644 --- a/webview-ui/src/components/settings/ProfileThresholdManager.tsx +++ b/webview-ui/src/components/settings/ProfileThresholdManager.tsx @@ -1,4 +1,4 @@ -import { VSCodeButton, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" +import { VSCodeButton, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import React, { useState } from "react" import { useTranslation } from "react-i18next" import { ProviderSettingsEntry } from "@roo-code/types" @@ -71,17 +71,21 @@ export const ProfileThresholdManager: React.FC = ( } return ( -
-
-
From 8e31fbdf4fc05a929cfa8e92b155fb1b8bd5eeaf Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 20 Jun 2025 22:58:33 -0400 Subject: [PATCH 16/16] Update src/core/condense/index.ts --- src/core/condense/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/condense/index.ts b/src/core/condense/index.ts index a57832a1b5..3b73b1915c 100644 --- a/src/core/condense/index.ts +++ b/src/core/condense/index.ts @@ -8,7 +8,7 @@ import { ApiMessage } from "../task-persistence/apiMessages" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" export const N_MESSAGES_TO_KEEP = 3 -export const MIN_CONDENSE_THRESHOLD = 10 // Minimum percentage of context window to trigger condensing +export const MIN_CONDENSE_THRESHOLD = 5 // Minimum percentage of context window to trigger condensing export const MAX_CONDENSE_THRESHOLD = 100 // Maximum percentage of context window to trigger condensing const SUMMARY_PROMPT = `\